diff --git a/README.md b/README.md index 279e8455..bfae825d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The module is designed to be instantiated many times, once for each desired land This is currently split logically into the following capabilities: - Subscription creation and management group placement + - Microsoft Defender for Cloud (DFC) security contact - Networking - deploy multiple vnets with: - Hub & spoke connectivity (peering to a hub network) - vWAN connectivity @@ -65,6 +66,12 @@ module "lz_vending" { subscription_management_group_association_enabled = true subscription_management_group_id = "Corp" + # defender for cloud variables + subscription_dfc_contact_enabled = true + subscription_dfc_contact = { + emails = "john@microsoft.com;jane@microsoft.com" + } + # virtual network variables virtual_network_enabled = true virtual_networks = { @@ -480,6 +487,43 @@ Type: `string` Default: `""` +### [subscription\_dfc\_contact](#input\_subscription\_dfc\_contact) + +Description: Microsoft Defender for Cloud (DFC) contact and notification configurations + +### Security Contact Information - Determines who'll get email notifications from Defender for Cloud + +- `notifications_by_role`: All users with these specific RBAC roles on the subscription will get email notifications. [optional - allowed values are: `AccountAdmin`, `ServiceAdmin`, `Owner` and `Contributor` - default empty] +- `emails`: List of additional email addresses which will get notifications. Multiple emails can be provided in a ; separated list. Example: "john@microsoft.com;jane@microsoft.com". [optional - default empty] +- `phone`: The security contact's phone number. [optional - default empty] +> **Note**: At least one role or email address must be provided to enable alert notification. + +### Alert Notifications + +- `alert_notifications`: Enables email notifications and defines the minimal alert severity. [optional - allowed values are: `Off`, `High`, `Medium` or `Low` - default `Off`] + +Type: + +```hcl +object({ + notifications_by_role = optional(list(string), []) + emails = optional(string, "") + phone = optional(string, "") + alert_notifications = optional(string, "Off") + }) +``` + +Default: `{}` + +### [subscription\_dfc\_contact\_enabled](#input\_subscription\_dfc\_contact\_enabled) + +Description: Whether to enable Microsoft Defender for Cloud (DFC) contact settings on the subscription. [optional - default `false`] +If enabled, provide settings in var.subscription\_dfc\_contact + +Type: `bool` + +Default: `false` + ### [subscription\_display\_name](#input\_subscription\_display\_name) Description: The display name of the subscription alias. diff --git a/header.md b/header.md index eff5384b..664d9168 100644 --- a/header.md +++ b/header.md @@ -13,6 +13,7 @@ The module is designed to be instantiated many times, once for each desired land This is currently split logically into the following capabilities: - Subscription creation and management group placement + - Microsoft Defender for Cloud (DFC) security contact - Networking - deploy multiple vnets with: - Hub & spoke connectivity (peering to a hub network) - vWAN connectivity @@ -64,6 +65,12 @@ module "lz_vending" { subscription_management_group_association_enabled = true subscription_management_group_id = "Corp" + # defender for cloud variables + subscription_dfc_contact_enabled = true + subscription_dfc_contact = { + emails = "john@microsoft.com;jane@microsoft.com" + } + # virtual network variables virtual_network_enabled = true virtual_networks = { diff --git a/main.subscription.tf b/main.subscription.tf index 0d1d0e2b..86682f88 100644 --- a/main.subscription.tf +++ b/main.subscription.tf @@ -17,4 +17,11 @@ module "subscription" { subscription_update_existing = var.subscription_update_existing subscription_workload = var.subscription_workload wait_for_subscription_before_subscription_operations = var.wait_for_subscription_before_subscription_operations + subscription_dfc_contact_enabled = var.subscription_dfc_contact_enabled + subscription_dfc_contact = { + emails = var.subscription_dfc_contact.emails + phone = var.subscription_dfc_contact.phone + alert_notifications = var.subscription_dfc_contact.alert_notifications + notifications_by_role = var.subscription_dfc_contact.notifications_by_role + } } diff --git a/modules/subscription/README.md b/modules/subscription/README.md index ef2d5c20..b1bc2e8f 100644 --- a/modules/subscription/README.md +++ b/modules/subscription/README.md @@ -3,7 +3,11 @@ ## Overview -Creates a subscription alias, and optionally manages management group association for the resulting subscription. +Creates a subscription alias +Optionally: + +- Associates the resulting subscription to a management group +- Creates the Microsoft Defender for Cloud (DFC) security contact and enables notifications ## Notes @@ -21,6 +25,13 @@ module "subscription" { subscription_alias_name = "my-subscription-alias" subscription_alias_workload = "Production" subscription_alias_management_group_id = "mymg" + subscription_dfc_contact_enabled = true + subscription_dfc_contact = { + notifications_by_role = ["Owner", "Contributor"] + emails = "john@microsoft.com;jane@microsoft.com" + phone = "+1-555-555-5555" + alert_notifications = "Medium" + } } ``` @@ -111,6 +122,43 @@ Type: `string` Default: `""` +### [subscription\_dfc\_contact](#input\_subscription\_dfc\_contact) + +Description: Microsoft Defender for Cloud (DFC) contact and notification configurations + +### Security Contact Information - Determines who'll get email notifications from Defender for Cloud + +- `notifications_by_role`: All users with these specific RBAC roles on the subscription will get email notifications. [optional - allowed values are: `AccountAdmin`, `ServiceAdmin`, `Owner` and `Contributor` - default empty] +- `emails`: List of additional email addresses which will get notifications. Multiple emails can be provided in a ; separated list. Example: "john@microsoft.com;jane@microsoft.com". [optional - default empty] +- `phone`: The security contact's phone number. [optional - default empty] +> **Note**: At least one role or email address must be provided to enable alert notification. + +### Alert Notifications + +- `alert_notifications`: Enables email notifications and defines the minimal alert severity. [optional - allowed values are: `Off`, `High`, `Medium` or `Low` - default `Off`] + +Type: + +```hcl +object({ + notifications_by_role = optional(list(string), []) + emails = optional(string, "") + phone = optional(string, "") + alert_notifications = optional(string, "Off") + }) +``` + +Default: `{}` + +### [subscription\_dfc\_contact\_enabled](#input\_subscription\_dfc\_contact\_enabled) + +Description: Whether to enable Microsoft Defender for Cloud (DFC) contact settings on the subscription. [optional - default `false`] +If enabled, provide settings in var.subscription\_dfc\_contact + +Type: `bool` + +Default: `false` + ### [subscription\_display\_name](#input\_subscription\_display\_name) Description: The display name of the subscription alias. @@ -224,6 +272,7 @@ Default: `{}` The following resources are used by this module: - [azapi_resource.subscription](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) (resource) +- [azapi_resource.subscription_dfc_contact](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource) (resource) - [azapi_resource_action.subscription_association](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource_action) (resource) - [azapi_resource_action.subscription_cancel](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource_action) (resource) - [azapi_resource_action.subscription_rename](https://registry.terraform.io/providers/Azure/azapi/latest/docs/resources/resource_action) (resource) diff --git a/modules/subscription/header.md b/modules/subscription/header.md index bce19e69..9ebc777e 100644 --- a/modules/subscription/header.md +++ b/modules/subscription/header.md @@ -2,7 +2,11 @@ ## Overview -Creates a subscription alias, and optionally manages management group association for the resulting subscription. +Creates a subscription alias +Optionally: + +- Associates the resulting subscription to a management group +- Creates the Microsoft Defender for Cloud (DFC) security contact and enables notifications ## Notes @@ -20,5 +24,12 @@ module "subscription" { subscription_alias_name = "my-subscription-alias" subscription_alias_workload = "Production" subscription_alias_management_group_id = "mymg" + subscription_dfc_contact_enabled = true + subscription_dfc_contact = { + notifications_by_role = ["Owner", "Contributor"] + emails = "john@microsoft.com;jane@microsoft.com" + phone = "+1-555-555-5555" + alert_notifications = "Medium" + } } ``` diff --git a/modules/subscription/main.tf b/modules/subscription/main.tf index 27dc4d41..c92facc2 100644 --- a/modules/subscription/main.tf +++ b/modules/subscription/main.tf @@ -121,3 +121,35 @@ resource "azapi_resource_action" "subscription_cancel" { time_sleep.wait_for_subscription_before_subscription_operations ] } + +resource "azapi_resource" "subscription_dfc_contact" { + count = var.subscription_dfc_contact_enabled ? 1 : 0 + parent_id = "/subscriptions/${local.subscription_id}" + type = "Microsoft.Security/securityContacts@2020-01-01-preview" + name = "default" // The only valid name for security contact is 'default' + + body = jsonencode({ + properties = { + emails = var.subscription_dfc_contact.emails + phone = var.subscription_dfc_contact.phone + + alertNotifications = { + state = var.subscription_dfc_contact.alert_notifications == "Off" ? var.subscription_dfc_contact.alert_notifications : "On" + minimalSeverity = var.subscription_dfc_contact.alert_notifications == "Off" ? "" : var.subscription_dfc_contact.alert_notifications + } + + notificationsByRole = { + state = length(var.subscription_dfc_contact.notifications_by_role) > 0 ? "On" : "Off" + roles = var.subscription_dfc_contact.notifications_by_role + } + } + }) + schema_validation_enabled = false + lifecycle { + ignore_changes = [location] + } + + depends_on = [ + time_sleep.wait_for_subscription_before_subscription_operations + ] +} diff --git a/modules/subscription/testdata/TestDeploySubscriptionAliasDfcContactValid/main.tf b/modules/subscription/testdata/TestDeploySubscriptionAliasDfcContactValid/main.tf new file mode 100644 index 00000000..d602b5a8 --- /dev/null +++ b/modules/subscription/testdata/TestDeploySubscriptionAliasDfcContactValid/main.tf @@ -0,0 +1,52 @@ +variable "subscription_billing_scope" { + type = string +} + +variable "subscription_alias_name" { + type = string +} + +variable "subscription_display_name" { + type = string +} + +variable "subscription_workload" { + type = string +} + +variable "subscription_alias_enabled" { + type = bool +} + +variable "subscription_use_azapi" { + type = bool +} + +variable "subscription_dfc_contact_enabled" { + type = bool +} + +variable "subscription_dfc_contact" { + type = object({ + emails = optional(string, "") + phone = optional(string, "") + alert_notifications = optional(string, "Off") + notifications_by_role = optional(list(string), []) + }) +} + +module "subscription_test" { + source = "../../" + subscription_alias_name = var.subscription_alias_name + subscription_display_name = var.subscription_display_name + subscription_workload = var.subscription_workload + subscription_billing_scope = var.subscription_billing_scope + subscription_alias_enabled = var.subscription_alias_enabled + subscription_use_azapi = var.subscription_use_azapi + subscription_dfc_contact_enabled = var.subscription_dfc_contact_enabled + subscription_dfc_contact = var.subscription_dfc_contact +} + +output "subscription_id" { + value = module.subscription_test.subscription_id +} diff --git a/modules/subscription/testdata/TestDeploySubscriptionAliasDfcContactValid/terraform.tf b/modules/subscription/testdata/TestDeploySubscriptionAliasDfcContactValid/terraform.tf new file mode 100644 index 00000000..bc826544 --- /dev/null +++ b/modules/subscription/testdata/TestDeploySubscriptionAliasDfcContactValid/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.3.0" + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.7.0" + } + azapi = { + source = "Azure/azapi" + version = ">= 1.0.0" + } + } +} diff --git a/modules/subscription/variables.tf b/modules/subscription/variables.tf index 41d940ae..59343935 100644 --- a/modules/subscription/variables.tf +++ b/modules/subscription/variables.tf @@ -196,3 +196,68 @@ variable "wait_for_subscription_before_subscription_operations" { The duration to wait after vending a subscription before performing subscription operations. DESCRIPTION } + +variable "subscription_dfc_contact_enabled" { + type = bool + default = false + description = < **Note**: At least one role or email address must be provided to enable alert notification. + +### Alert Notifications + +- `alert_notifications`: Enables email notifications and defines the minimal alert severity. [optional - allowed values are: `Off`, `High`, `Medium` or `Low` - default `Off`] + +DESCRIPTION + + # validate email addresses + validation { + condition = (var.subscription_dfc_contact.emails == "" || can(regex("^([\\w+-.%]+@[\\w.-]+\\.[A-Za-z]{2,4})(;[\\w+-.%]+@[\\w.-]+\\.[A-Za-z]{2,4})*$", var.subscription_dfc_contact.emails))) + error_message = "Invalid email address(es) provided. Multiple emails must be separated with a `;`" + } + + # validate phone number + validation { + condition = (var.subscription_dfc_contact.phone == "" || can(regex("^[\\+0-9-]+$", var.subscription_dfc_contact.phone))) + error_message = "Invalid phone number provided. Valid characters are 0-9, '-', and '+'. An example for a valid phone number is: +1-555-555-5555" + } + + # validate alert notifications + validation { + condition = contains(["Off", "High", "Medium", "Low"], var.subscription_dfc_contact.alert_notifications) + error_message = "Invalid alert_notifications_state. Valid options are Off, High, Medium, Low." + } + + # validate notifications by role + validation { + condition = alltrue([for role in var.subscription_dfc_contact.notifications_by_role : contains(["Owner", "AccountAdmin", "Contributor", "ServiceAdmin"], role)]) + error_message = "Invalid notifications_by_role. The supported RBAC roles are: AccountAdmin, ServiceAdmin, Owner, Contributor." + } + + # validate that when alert notifications are enabled, an email or role is also provided + validation { + condition = (var.subscription_dfc_contact.alert_notifications == "Off" ? true : var.subscription_dfc_contact.emails != "" || length(var.subscription_dfc_contact.notifications_by_role) > 0) + error_message = "To enable alert notifications, either an email address or role must be provided." + } + +} diff --git a/tests/subscription/subscriptionDeploy_test.go b/tests/subscription/subscriptionDeploy_test.go index f9f0ab09..15906026 100644 --- a/tests/subscription/subscriptionDeploy_test.go +++ b/tests/subscription/subscriptionDeploy_test.go @@ -56,7 +56,7 @@ func TestDeploySubscriptionAliasValid(t *testing.T) { require.NoErrorf(t, err, "subscription id %s is not a valid uuid", sid) } -// TestDeploySubscriptionAliasValid tests the deployment of a subscription alias +// TestDeploySubscriptionAliasValidAzApi tests the deployment of a subscription alias // with valid input variables. // We also test RP registration here. // This test uses the azapi provider. @@ -142,7 +142,7 @@ func TestDeploySubscriptionAliasManagementGroupValid(t *testing.T) { assert.NoErrorf(t, err, "subscription %s is not in management group %s", sid, v["subscription_management_group_id"].(string)) } -// TestDeploySubscriptionAliasManagementGroupValid tests the deployment of a subscription alias +// TestDeploySubscriptionAliasManagementGroupValidAzApi tests the deployment of a subscription alias // with valid input variables. func TestDeploySubscriptionAliasManagementGroupValidAzApi(t *testing.T) { t.Parallel() @@ -192,6 +192,56 @@ func TestDeploySubscriptionAliasManagementGroupValidAzApi(t *testing.T) { } } +// TestDeploySubscriptionAliasDfcContactValid tests the deployment of a subscription alias +// with valid Defender for Cloud contact settings variables. +func TestDeploySubscriptionAliasDfcContactValid(t *testing.T) { + t.Parallel() + utils.PreCheckDeployTests(t) + + v, err := getValidInputVariables(billingScope) + require.NoError(t, err) + v["subscription_billing_scope"] = billingScope + v["subscription_dfc_contact_enabled"] = true + v["subscription_dfc_contact"] = map[string]any{ + "emails": "test@contoso.com", + "phone": "+555-555-5555", + "alert_notifications": "High", + "notifications_by_role": []string{"Owner"}, + } + + testDir := filepath.Join("testdata", t.Name()) + test, err := setuptest.Dirs(moduleDir, testDir).WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders) + require.NoError(t, err) + defer test.Cleanup() + require.NoError(t, err) + + // Defer the cleanup of the subscription alias to the end of the test. + // Should be run after the Terraform destroy. + // We don't know the sub ID yet, so use zeros for now and then + // update it after the apply. + u := uuid.MustParse("00000000-0000-0000-0000-000000000000") + defer func() { + err := azureutils.CancelSubscription(t, &u) + if err != nil { + t.Logf("cannot cancel subscription: %v", err) + } + }() + + // defer terraform destroy, but wrap in a try.Do to retry a few times + // due to eventual consistency of the subscription aliases API + defer test.DestroyRetry(setuptest.DefaultRetry) //nolint:errcheck + test.ApplyIdempotent().ErrorIsNil(t) + + sid, err := terraform.OutputE(t, test.Options, "subscription_id") + assert.NoError(t, err) + + u, err = uuid.Parse(sid) + assert.NoErrorf(t, err, "subscription id %s is not a valid uuid", sid) + + err = azureutils.IsSubscriptionInManagementGroup(t, u, v["subscription_management_group_id"].(string)) + assert.NoErrorf(t, err, "subscription %s is not in management group %s", sid, v["subscription_management_group_id"].(string)) +} + // getValidInputVariables returns a set of valid input variables that can be used and modified for testing scenarios. func getValidInputVariables(billingScope string) (map[string]any, error) { r, err := utils.RandomHex(4) diff --git a/tests/subscription/subscription_test.go b/tests/subscription/subscription_test.go index 434cf245..adc43f7e 100644 --- a/tests/subscription/subscription_test.go +++ b/tests/subscription/subscription_test.go @@ -259,6 +259,37 @@ func TestSubscriptionInvalidTagName(t *testing.T) { assert.Contains(t, utils.SanitiseErrorMessage(err), "Tag name must contain neither `<>%&\\?/` nor control characters, and must be between 0-512 characters.") } +// TestSubscriptionAliasCreateValidWithDfcContact tests the +// validation functions with valid data, including a Defender for Cloud contact, +// then creates a plan and compares the input variables to the planned values. +func TestSubscriptionAliasCreateValidWithDfcContact(t *testing.T) { + t.Parallel() + + v := getMockInputVariables() + v["subscription_dfc_contact_enabled"] = true + v["subscription_dfc_contact"] = map[string]any{ + "emails": "test@contoso.com", + "phone": "+555-555-5555", + "alert_notifications": "High", + "notifications_by_role": []string{"Owner"}, + } + + test, err := setuptest.Dirs(moduleDir, "").WithVars(v).InitPlanShowWithPrepFunc(t, utils.AzureRmAndRequiredProviders) + require.NoError(t, err) + defer test.Cleanup() + + check.InPlan(test.PlanStruct).NumberOfResourcesEquals(2).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azurerm_subscription.this[0]").Key("alias").HasValue(v["subscription_alias_name"]).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azurerm_subscription.this[0]").Key("billing_scope_id").HasValue(v["subscription_billing_scope"]).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azurerm_subscription.this[0]").Key("subscription_name").HasValue(v["subscription_display_name"]).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azurerm_subscription.this[0]").Key("workload").HasValue(v["subscription_workload"]).ErrorIsNil(t) + + check.InPlan(test.PlanStruct).That("azapi_resource.subscription_dfc_contact[0]").Key("body").Query("properties.emails").HasValue(v["subscription_dfc_contact"].(map[string]any)["emails"]).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azapi_resource.subscription_dfc_contact[0]").Key("body").Query("properties.phone").HasValue(v["subscription_dfc_contact"].(map[string]any)["phone"]).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azapi_resource.subscription_dfc_contact[0]").Key("body").Query("properties.alertNotifications.minimalSeverity").HasValue(v["subscription_dfc_contact"].(map[string]any)["alert_notifications"]).ErrorIsNil(t) + check.InPlan(test.PlanStruct).That("azapi_resource.subscription_dfc_contact[0]").Key("body").Query("properties.notificationsByRole.roles.0").HasValue(v["subscription_dfc_contact"].(map[string]any)["notifications_by_role"].([]string)[0]).ErrorIsNil(t) +} + // getMockInputVariables returns a set of mock input variables that can be used and modified for testing scenarios. func getMockInputVariables() map[string]any { return map[string]any{ diff --git a/variables.subscription.tf b/variables.subscription.tf index cd937c98..99777a91 100644 --- a/variables.subscription.tf +++ b/variables.subscription.tf @@ -197,3 +197,38 @@ variable "wait_for_subscription_before_subscription_operations" { The duration to wait after vending a subscription before performing subscription operations. DESCRIPTION } + +variable "subscription_dfc_contact_enabled" { + type = bool + default = false + description = < **Note**: At least one role or email address must be provided to enable alert notification. + +### Alert Notifications + +- `alert_notifications`: Enables email notifications and defines the minimal alert severity. [optional - allowed values are: `Off`, `High`, `Medium` or `Low` - default `Off`] + +DESCRIPTION +}