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
+}