diff --git a/.changeset/metal-comics-clap.md b/.changeset/metal-comics-clap.md new file mode 100644 index 0000000000..d2ad64b0ee --- /dev/null +++ b/.changeset/metal-comics-clap.md @@ -0,0 +1,5 @@ +--- +"azure_app_configuration": patch +--- + +Initial version diff --git a/apps/website/docs/azure/app-configuration/azure-app-configuration.md b/apps/website/docs/azure/app-configuration/azure-app-configuration.md index 4e636cebb1..76eab4e249 100644 --- a/apps/website/docs/azure/app-configuration/azure-app-configuration.md +++ b/apps/website/docs/azure/app-configuration/azure-app-configuration.md @@ -12,74 +12,31 @@ exploiting the hot reload capabilities. ## Configuring the resource via Terraform -You can use the following Terraform code to create an Azure App Configuration -instance. Make sure to adapt the naming conventions and resource group according -to your environment. The example below creates a standard +You can use the Terraform module +[`azure_app_configuration`](https://registry.terraform.io/modules/pagopa-dx/azure-app-configuration/azurerm/latest) +to create an Azure App Configuration instance. The module usage - showed in the +example below - creates a standard [SKU App Configuration](https://azure.microsoft.com/en-us/pricing/details/app-configuration/) -instance with system-assigned identity, private endpoint connectivity, and +instance with private endpoint connectivity, Entra ID authentication, and [purge protection enabled](https://learn.microsoft.com/en-us/azure/azure-app-configuration/howto-recover-deleted-stores-in-azure-app-configuration). ```hcl -resource "azurerm_app_configuration" "example" { - name = provider::azuredx::resource_name(merge( - var.naming_config, - { - name = "demo", - resource_type = "app_configuration", - }) - ) - resource_group_name = local.resource_group_name - location = local.environment.location - - identity { - type = "SystemAssigned" - } - - sku = "standard" # others are free and premium - data_plane_proxy_authentication_mode = "Pass-through" - local_auth_enabled = false +module "appcs" { + source = "pagopa-dx/azure-app-configuration/azurerm" + version = "~> 0.0" - public_network_access = "Disabled" - purge_protection_enabled = true - - tags = local.tags -} + environment = local.environment + resource_group_name = var.resource_group_name -data "azurerm_private_dns_zone" "appconfig" { - name = "privatelink.azconfig.io" - resource_group_name = var.private_dns_zone_resource_group_name -} + subnet_pep_id = data.azurerm_subnet.pep.id -resource "azurerm_private_endpoint" "app_config" { - name = provider::azuredx::resource_name(merge( - var.naming_config, - { - name = "demo", - resource_type = "app_configuration_private_endpoint", - }) - ) - location = local.environment.location - resource_group_name = local.resource_group_name - subnet_id = var.subnet_pep_id - - private_service_connection { - name = provider::azuredx::resource_name(merge( - var.naming_config, - { - name = "demo", - resource_type = "app_configuration_private_endpoint", - }) - ) - private_connection_resource_id = azurerm_app_configuration.example.id - is_manual_connection = false - subresource_names = ["configurationStores"] + virtual_network = { + name = local.virtual_network.name + resource_group_name = local.virtual_network.resource_group_name } - private_dns_zone_group { - name = "private-dns-zone-group" - private_dns_zone_ids = [data.azurerm_private_dns_zone.appconfig.id] - } + private_dns_zone_resource_group_name = data.azurerm_resource_group.network.name tags = local.tags } @@ -88,7 +45,7 @@ module "roles" { source = "pagopa-dx/azure-role-assignments/azurerm" version = "~> 1.3" - principal_id = module.test_app.app_service.app_service.principal_id + principal_id = module.test_app.app_service.app_service.principal_id # example application which needs to access App Configuration subscription_id = data.azurerm_subscription.current.subscription_id app_config = [ @@ -124,28 +81,27 @@ provider "azurerm" { If your application has sensitive application settings (secrets), the AppConfiguration instance should be configured to retrieve those secrets from Azure Key Vault, to make them available to the application. The authentication -via identities between AppConfiguration and KeyVault is managed via Terraform: +via identities between AppConfiguration and KeyVault is managed by the module +[`azure_app_configuration`](https://registry.terraform.io/modules/pagopa-dx/azure-app-configuration/azurerm/latest), +which optionally accepts a KeyVault reference: ```hcl -module "roles" { - source = "pagopa-dx/azure-role-assignments/azurerm" - version = "~> 1.3" - principal_id = azurerm_app_configuration.example.identity[0].principal_id - subscription_id = data.azurerm_subscription.current.subscription_id +module "appcs_with_kv" { + source = "pagopa-dx/azure-app-configuration/azurerm" + version = "~> 0.0" - app_config = [ - { - name = azurerm_app_configuration.example.name - resource_group_name = azurerm_app_configuration.example.resource_group_name - has_rbac_support = true # or false if KeyVault is using Access Policies - description = "Complete access to app configuration control plane and data" - roles = { - secrets = "Reader" # Allow AppConfiguration to read secrets from KeyVault - } - } - ] + ... + + key_vault = { + subscription_id = data.azurerm_subscription.current.subscription_id + name = azurerm_key_vault.kv.name + resource_group_name = azurerm_key_vault.kv.resource_group_name + has_rbac_support = true # or false if KeyVault uses Access Policies + } + + tags = local.tags } ``` diff --git a/infra/modules/azure_app_configuration/CHANGELOG.md b/infra/modules/azure_app_configuration/CHANGELOG.md new file mode 100644 index 0000000000..f3ae2af22f --- /dev/null +++ b/infra/modules/azure_app_configuration/CHANGELOG.md @@ -0,0 +1 @@ +# azure_app_configuration diff --git a/infra/modules/azure_app_configuration/README.md b/infra/modules/azure_app_configuration/README.md new file mode 100644 index 0000000000..7c948b6d36 --- /dev/null +++ b/infra/modules/azure_app_configuration/README.md @@ -0,0 +1,79 @@ +# DX - Azure App Configuration + +![Terraform Module Downloads](https://img.shields.io/terraform/module/dm/pagopa-dx/azure-app-configuration/azurerm?logo=terraform&label=downloads&cacheSeconds=5000&link=https%3A%2F%2Fregistry.terraform.io%2Fmodules%2Fpagopa-dx%2Fazure-app-configuration%2Fazurerm%2Flatest) + +This module deploys an Azure App Configuration, opinionated setup in terms of networking setup and access mode. + +## Features + +- **App Configuration**: An Azure App Configuration instance to retrieve application settings, secrets and feature flags +- **Private Endpoint**: To allow only private incoming connections + +## Use cases Comparison + +| Use case | Description | SLA | Guaranteed Throughput | Request Quota | Soft Delete | Geo Replication | +| --------- | ------------------------------------------------- | ------ | ------------------------------- | --------------- | ----------- | --------------- | +| default | Tier for production workfloads | 99.95% | 300 RPS (read) - 60 RPS (write) | 30.000 per hour | Yes | Yes | +| developer | Tier for experimentation or development scenarios | - | - | 6.000 per hour | No | No | + +### Allowed Sizes + +The SKU name is determined by the use case, but if you want to override it, you can set the `size` variable when `use_case` is set to `default`. +The allowed sizes are: + +- `standard` +- `premium` + +## Usage Example + +For examples of how to use this module, refer to the [examples](https://github.com/pagopa-dx/terraform-azurerm-azure-app-configuration/tree/main/examples) folder in the module repository. + + +## Requirements + +| Name | Version | +|------|---------| +| [azurerm](#requirement\_azurerm) | ~> 4.0 | +| [pagopa-dx](#requirement\_pagopa-dx) | ~> 0.8 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [app\_roles](#module\_app\_roles) | pagopa-dx/azure-role-assignments/azurerm | ~> 1.3 | +| [appconfig\_team\_roles](#module\_appconfig\_team\_roles) | pagopa-dx/azure-role-assignments/azurerm | ~> 1.3 | + +## Resources + +| Name | Type | +|------|------| +| [azurerm_app_configuration.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/app_configuration) | resource | +| [azurerm_private_endpoint.app_config](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) | resource | +| [azurerm_private_dns_zone.appconfig](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/private_dns_zone) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [authorized\_teams](#input\_authorized\_teams) | Object containing lists of principal IDs (Azure AD object IDs) of product teams to be granted read or write permissions on the App Configuration. These represent the teams within the organization that need access to this resource." |
object({
writers = optional(list(string), []),
readers = optional(list(string), [])
})
|
{
"readers": [],
"writers": []
}
| no | +| [environment](#input\_environment) | Values which are used to generate resource names and location short names. They are all mandatory except for domain, which should not be used only in the case of a resource used by multiple domains. |
object({
prefix = string
env_short = string
location = string
domain = optional(string)
app_name = string
instance_number = string
})
| n/a | yes | +| [key\_vaults](#input\_key\_vaults) | Optionally, integrate App Configuration with a one or more existing Key Vault for secrets retrieval.
Set `has_rbac_support` to true if the referenced Key Vault uses RBAC model for access control.
Use `app_principal_ids` to set application principal IDs to be granted access to the Key Vault. |
list(object({
name = string
resource_group_name = string
has_rbac_support = bool
app_principal_ids = list(string)
}))
| `null` | no | +| [private\_dns\_zone\_resource\_group\_name](#input\_private\_dns\_zone\_resource\_group\_name) | Name of the resource group containing the private DNS zone for private endpoints. Default is the resource group of the virtual network. | `string` | `null` | no | +| [resource\_group\_name](#input\_resource\_group\_name) | The name of the resource group where resources will be deployed. | `string` | n/a | yes | +| [size](#input\_size) | "App Configuration SKU. Allowed values: 'standard', 'premium'. If not set, it will be determined by the use\_case." | `string` | `null` | no | +| [subnet\_pep\_id](#input\_subnet\_pep\_id) | ID of the subnet hosting private endpoints. | `string` | n/a | yes | +| [subscription\_id](#input\_subscription\_id) | Subscription Id of the involved resources | `string` | n/a | yes | +| [tags](#input\_tags) | A map of tags to assign to the resources. | `map(any)` | n/a | yes | +| [use\_case](#input\_use\_case) | Allowed values: 'default', 'development'. | `string` | `"default"` | no | +| [virtual\_network](#input\_virtual\_network) | Virtual network where the subnet will be created. |
object({
name = string
resource_group_name = string
})
| n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [endpoint](#output\_endpoint) | The service endpoint URL | +| [id](#output\_id) | The ID of the Azure Cosmos DB account. | +| [name](#output\_name) | The name of the Azure Cosmos DB account. | +| [principal\_id](#output\_principal\_id) | The system-assigned managed identity pricipal id | +| [resource\_group\_name](#output\_resource\_group\_name) | The name of the resource group containing the Azure Cosmos DB account. | + diff --git a/infra/modules/azure_app_configuration/appcs.tf b/infra/modules/azure_app_configuration/appcs.tf new file mode 100644 index 0000000000..04cdfbbe05 --- /dev/null +++ b/infra/modules/azure_app_configuration/appcs.tf @@ -0,0 +1,18 @@ +resource "azurerm_app_configuration" "this" { + name = provider::pagopa-dx::resource_name(merge(local.naming_config, { resource_type = "app_configuration" })) + resource_group_name = var.resource_group_name + location = var.environment.location + + identity { + type = "SystemAssigned" + } + + sku = local.appcs.sku_name + data_plane_proxy_authentication_mode = "Pass-through" + local_auth_enabled = false + + public_network_access = "Disabled" + purge_protection_enabled = local.use_case_features.purge_protection_enabled + + tags = local.tags +} diff --git a/infra/modules/azure_app_configuration/data.tf b/infra/modules/azure_app_configuration/data.tf new file mode 100644 index 0000000000..dca91a7358 --- /dev/null +++ b/infra/modules/azure_app_configuration/data.tf @@ -0,0 +1,4 @@ +data "azurerm_private_dns_zone" "appconfig" { + name = "privatelink.azconfig.io" + resource_group_name = local.private_dns_zone.resource_group_name +} diff --git a/infra/modules/azure_app_configuration/iam.tf b/infra/modules/azure_app_configuration/iam.tf new file mode 100644 index 0000000000..f9c2c5d1d8 --- /dev/null +++ b/infra/modules/azure_app_configuration/iam.tf @@ -0,0 +1,91 @@ +locals { + # Distinct principal IDs from all Key Vaults + distinct_app_principals = var.key_vaults != null ? toset(flatten([ + for kv in var.key_vaults : kv.app_principal_ids + ])) : toset([]) + + # For each distinct principal, collect all Key Vaults where they're listed + app_assignments = { + for principal_id in local.distinct_app_principals : + principal_id => { + principal_id = principal_id + key_vaults = [ + for kv in var.key_vaults : + { + name = kv.name + resource_group_name = kv.resource_group_name + has_rbac_support = kv.has_rbac_support + } + if contains(kv.app_principal_ids, principal_id) + ] + } + } + + # App Configuration role assignments for authorized teams + appconfig_role_assignments = merge( + { + for idx, principal_id in var.authorized_teams.writers : "${principal_id}|writer" => { + principal_id = principal_id + role = "writer" + } + }, + { + for idx, principal_id in var.authorized_teams.readers : "${principal_id}|reader" => { + principal_id = principal_id + role = "reader" + } + } + ) +} + +# Assign both Key Vault and App Configuration roles to application principals +# Each principal gets: +# - Roles for all Key Vaults where they are listed +# - A single reader role for the App Configuration +module "app_roles" { + for_each = local.app_assignments + + source = "pagopa-dx/azure-role-assignments/azurerm" + version = "~> 1.3" + + subscription_id = var.subscription_id + principal_id = each.value.principal_id + + key_vault = [ + for kv in each.value.key_vaults : { + name = kv.name + resource_group_name = kv.resource_group_name + has_rbac_support = kv.has_rbac_support + description = "Allow application to read Key Vault secrets" + roles = { + secrets = "reader" + } + } + ] + + app_config = [{ + name = azurerm_app_configuration.this.name + resource_group_name = azurerm_app_configuration.this.resource_group_name + description = "Allow application to read App Configuration settings" + role = "reader" + }] +} + +module "appconfig_team_roles" { + for_each = local.appconfig_role_assignments + + source = "pagopa-dx/azure-role-assignments/azurerm" + version = "~> 1.3" + + subscription_id = var.subscription_id + principal_id = each.value.principal_id + + app_config = [ + { + name = azurerm_app_configuration.this.name + resource_group_name = azurerm_app_configuration.this.resource_group_name + description = "Allow team to access App Configuration" + role = each.value.role + } + ] +} diff --git a/infra/modules/azure_app_configuration/locals.tf b/infra/modules/azure_app_configuration/locals.tf new file mode 100644 index 0000000000..5c6d04b250 --- /dev/null +++ b/infra/modules/azure_app_configuration/locals.tf @@ -0,0 +1,34 @@ +locals { + tags = merge(var.tags, { ModuleSource = "DX", ModuleVersion = try(jsondecode(file("${path.module}/package.json")).version, "unknown"), ModuleName = try(jsondecode(file("${path.module}/package.json")).name, "unknown") }) + + naming_config = { + prefix = var.environment.prefix, + environment = var.environment.env_short, + location = var.environment.location + domain = var.environment.domain, + name = var.environment.app_name, + instance_number = tonumber(var.environment.instance_number), + } + + use_cases = { + default = { + sku = "standard" + purge_protection_enabled = true + } + development = { + sku = "developer" + purge_protection_enabled = false + } + } + + use_case_features = local.use_cases[var.use_case] + + private_dns_zone = { + resource_group_name = var.private_dns_zone_resource_group_name == null ? var.virtual_network.resource_group_name : var.private_dns_zone_resource_group_name + } + + appcs = { + sku_name = var.size != null ? var.size : local.use_case_features.sku + private_endpoint_name = provider::pagopa-dx::resource_name(merge(local.naming_config, { resource_type = "app_configuration_private_endpoint" })) + } +} diff --git a/infra/modules/azure_app_configuration/main.tf b/infra/modules/azure_app_configuration/main.tf new file mode 100644 index 0000000000..be94c1f1d3 --- /dev/null +++ b/infra/modules/azure_app_configuration/main.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + pagopa-dx = { + source = "pagopa-dx/azure" + version = "~> 0.8" + } + } +} diff --git a/infra/modules/azure_app_configuration/networking.tf b/infra/modules/azure_app_configuration/networking.tf new file mode 100644 index 0000000000..7d3e2bd205 --- /dev/null +++ b/infra/modules/azure_app_configuration/networking.tf @@ -0,0 +1,20 @@ +resource "azurerm_private_endpoint" "app_config" { + name = local.appcs.private_endpoint_name + location = var.environment.location + resource_group_name = var.resource_group_name + subnet_id = var.subnet_pep_id + + private_service_connection { + name = local.appcs.private_endpoint_name + private_connection_resource_id = azurerm_app_configuration.this.id + is_manual_connection = false + subresource_names = ["configurationStores"] + } + + private_dns_zone_group { + name = "private-dns-zone-group" + private_dns_zone_ids = [data.azurerm_private_dns_zone.appconfig.id] + } + + tags = local.tags +} diff --git a/infra/modules/azure_app_configuration/outputs.tf b/infra/modules/azure_app_configuration/outputs.tf new file mode 100644 index 0000000000..1b7ce54d9c --- /dev/null +++ b/infra/modules/azure_app_configuration/outputs.tf @@ -0,0 +1,24 @@ +output "name" { + description = "The name of the Azure Cosmos DB account." + value = azurerm_app_configuration.this.name +} + +output "id" { + description = "The ID of the Azure Cosmos DB account." + value = azurerm_app_configuration.this.id +} + +output "resource_group_name" { + description = "The name of the resource group containing the Azure Cosmos DB account." + value = azurerm_app_configuration.this.resource_group_name +} + +output "principal_id" { + description = "The system-assigned managed identity pricipal id" + value = azurerm_app_configuration.this.identity[0].principal_id +} + +output "endpoint" { + description = "The service endpoint URL" + value = azurerm_app_configuration.this.endpoint +} diff --git a/infra/modules/azure_app_configuration/package.json b/infra/modules/azure_app_configuration/package.json new file mode 100644 index 0000000000..cb6afc17e8 --- /dev/null +++ b/infra/modules/azure_app_configuration/package.json @@ -0,0 +1,14 @@ +{ + "name": "azure_app_configuration", + "version": "0.0.0", + "private": true, + "provider": "azurerm", + "description": "Privisions App Configuration instance with private connection and IAM access mode", + "scripts": { + "tf-init": "terraform init", + "test:unit": "terraform test -filter='tests/unit.tftest.hcl'", + "test:contract": "terraform test -filter='tests/contract.tftest.hcl'", + "test:integration": "terraform test -filter='tests/integration.tftest.hcl'", + "test:e2e": "go test -v -timeout 1h ./tests" + } +} diff --git a/infra/modules/azure_app_configuration/tests/contract.tftest.hcl b/infra/modules/azure_app_configuration/tests/contract.tftest.hcl new file mode 100644 index 0000000000..c1307a2139 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/contract.tftest.hcl @@ -0,0 +1,196 @@ +variables { + environment = { + prefix = "dx" + env_short = "d" + location = "italynorth" + domain = "modules" + app_name = "appcs" + instance_number = "01" + } + + tags = { + BusinessUnit = "DevEx" + CostCenter = "TS000 - Tecnologia e Servizi" + CreatedBy = "Terraform" + Environment = "Dev" + Source = "https://github.com/pagopa/dx/infra/modules/azure_app_configuration/tests" + ManagementTeam = "Developer Experience" + Test = "true" + TestName = "App Configuration contract tests" + } + + // Base inputs + resource_group_name = "rg-test" + virtual_network = { + name = "vnet-test" + resource_group_name = "rg-network" + } + private_dns_zone_resource_group_name = null + subnet_pep_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Network/virtualNetworks/vnet-test/subnets/snet-pep" + + // Defaults + use_case = "default" + size = null + key_vaults = null + authorized_teams = { + writers = [] + readers = [] + } + subscription_id = "00000000-0000-0000-0000-000000000000" +} + +mock_provider "azurerm" {} + +override_data { + target = data.azurerm_private_dns_zone.appconfig + values = { + id = "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/rg-network/providers/Microsoft.Network/privateDnsZones/privatelink.azconfig.io" + name = "privatelink.azconfig.io" + } +} + +run "invalid_size_value" { + command = plan + variables { size = "invalid" } + + expect_failures = [ + var.size, + ] +} + +run "valid_explicit_premium_size" { + command = plan + variables { size = "premium" } + + assert { + condition = azurerm_app_configuration.this.sku == "premium" + error_message = "Explicit size=premium must set SKU to premium" + } +} + +run "valid_explicit_standard_size" { + command = plan + variables { size = "standard" } + + assert { + condition = azurerm_app_configuration.this.sku == "standard" + error_message = "Explicit size=standard must set SKU to standard" + } +} + +run "invalid_use_case_development_premium" { + command = plan + variables { + use_case = "development" + size = "premium" + } + + expect_failures = [ + var.size, + ] +} + +run "key_vault_roles_contract" { + command = plan + variables { + key_vaults = [ + { + name = "kv-contract-test" + resource_group_name = "rg-kv-test" + has_rbac_support = true + app_principal_ids = ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"] + } + ] + } + + override_data { + target = module.app_roles["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"].module.key_vault.data.azurerm_client_config.current + values = { + tenant_id = "00000000-0000-0000-0000-000000000000" + } + } + + assert { + condition = length(module.app_roles) == 1 + error_message = "One module instance must exist for single principal" + } + + assert { + condition = length(local.app_assignments["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"].key_vaults) == 1 + error_message = "Principal must be assigned to one Key Vault" + } + + assert { + condition = length(local.distinct_app_principals) == 1 + error_message = "One distinct principal must be identified" + } +} + +run "authorized_teams_contract" { + command = plan + variables { + authorized_teams = { + writers = ["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] + readers = ["cccccccc-cccc-cccc-cccc-cccccccccccc"] + } + } + + assert { + condition = length(module.appconfig_team_roles) == 2 + error_message = "Two team role modules must be created (1 writer + 1 reader)" + } +} + +run "deduplication_across_key_vaults" { + command = plan + variables { + key_vaults = [ + { + name = "kv-1" + resource_group_name = "rg-kv" + has_rbac_support = true + app_principal_ids = ["dddddddd-dddd-dddd-dddd-dddddddddddd", "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"] + }, + { + name = "kv-2" + resource_group_name = "rg-kv" + has_rbac_support = true + app_principal_ids = ["dddddddd-dddd-dddd-dddd-dddddddddddd"] + } + ] + } + + override_data { + target = module.app_roles["dddddddd-dddd-dddd-dddd-dddddddddddd"].module.key_vault.data.azurerm_client_config.current + values = { + tenant_id = "00000000-0000-0000-0000-000000000000" + } + } + + override_data { + target = module.app_roles["eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"].module.key_vault.data.azurerm_client_config.current + values = { + tenant_id = "00000000-0000-0000-0000-000000000000" + } + } + + assert { + condition = length(module.app_roles) == 2 + error_message = "Only 2 distinct principals should create module instances" + } + + assert { + condition = length(local.distinct_app_principals) == 2 + error_message = "Two distinct principals must be identified" + } + + assert { + condition = length(local.app_assignments["dddddddd-dddd-dddd-dddd-dddddddddddd"].key_vaults) == 2 + error_message = "Principal dddd... must have access to 2 Key Vaults" + } + + assert { + condition = length(local.app_assignments["eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"].key_vaults) == 1 + error_message = "Principal eeee... must have access to 1 Key Vault" + } +} diff --git a/infra/modules/azure_app_configuration/tests/integration.tftest.hcl b/infra/modules/azure_app_configuration/tests/integration.tftest.hcl new file mode 100644 index 0000000000..490f866991 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/integration.tftest.hcl @@ -0,0 +1,183 @@ +provider "azurerm" { + features {} +} + +provider "pagopa-dx" {} + +variables { + environment = { + prefix = "dx" + env_short = "d" + location = "italynorth" + domain = "integration" + app_name = "appcs" + instance_number = "01" + } + + tags = { + BusinessUnit = "DevEx" + CostCenter = "TS000 - Tecnologia e Servizi" + CreatedBy = "Terraform" + Environment = "Dev" + Source = "https://github.com/pagopa/dx/infra/modules/azure_app_configuration/tests" + ManagementTeam = "Developer Experience" + Test = "true" + TestName = "App Configuration integration tests" + } + + use_case = "default" + test_kind = "integration" +} + +run "setup" { + module { + source = "./tests/setup" + } + variables { + environment = var.environment + test_kind = var.test_kind + tags = var.tags + } +} + +# Scenario 1: Default configuration (implicit standard SKU) +run "apply_default" { + command = apply + variables { + environment = var.environment + tags = var.tags + use_case = var.use_case + resource_group_name = run.setup.resource_group_name + virtual_network = run.setup.virtual_network + private_dns_zone_resource_group_name = run.setup.private_dns_zone_resource_group_name + subnet_pep_id = run.setup.subnet_pep_id + size = null + key_vaults = null + subscription_id = run.setup.subscription_id + authorized_teams = { + writers = [] + readers = [] + } + } + + assert { + condition = azurerm_app_configuration.this.sku == "standard" + error_message = "Default scenario must resolve SKU to standard" + } + assert { + condition = azurerm_app_configuration.this.public_network_access == "Disabled" + error_message = "Public network access must be Disabled" + } + assert { + condition = azurerm_private_endpoint.app_config.private_service_connection[0].subresource_names[0] == "configurationStores" + error_message = "Private endpoint subresource must be configurationStores" + } + assert { + condition = can(regex("privatelink\\.azconfig\\.io$", azurerm_private_endpoint.app_config.private_dns_zone_group[0].private_dns_zone_ids[0])) + error_message = "DNS zone group must reference privatelink.azconfig.io" + } + assert { + condition = azurerm_app_configuration.this.purge_protection_enabled == true + error_message = "Purge protection must be enabled" + } +} + +# Scenario 2: Explicit premium size +run "apply_premium" { + command = apply + variables { + environment = merge(var.environment, { app_name = "appcs-prem" }) + tags = var.tags + use_case = var.use_case + resource_group_name = run.setup.resource_group_name + virtual_network = run.setup.virtual_network + private_dns_zone_resource_group_name = run.setup.private_dns_zone_resource_group_name + subnet_pep_id = run.setup.subnet_pep_id + size = "premium" + key_vaults = null + subscription_id = run.setup.subscription_id + authorized_teams = { + writers = [] + readers = [] + } + } + + assert { + condition = azurerm_app_configuration.this.sku == "premium" + error_message = "Premium scenario must set SKU to premium" + } + assert { + condition = azurerm_app_configuration.this.public_network_access == "Disabled" + error_message = "Public network access must remain disabled" + } +} + +# Scenario 2: Explicit development size +run "apply_developer" { + command = apply + variables { + environment = merge(var.environment, { app_name = "appcs-dev" }) + tags = var.tags + use_case = "development" + resource_group_name = run.setup.resource_group_name + virtual_network = run.setup.virtual_network + private_dns_zone_resource_group_name = run.setup.private_dns_zone_resource_group_name + subnet_pep_id = run.setup.subnet_pep_id + key_vaults = null + subscription_id = run.setup.subscription_id + authorized_teams = { + writers = [] + readers = [] + } + } + + assert { + condition = azurerm_app_configuration.this.sku == "developer" + error_message = "Development scenario must set SKU to developer" + } + assert { + condition = azurerm_app_configuration.this.public_network_access == "Disabled" + error_message = "Public network access must remain disabled" + } +} + +# Scenario 4: Key Vault integration with application principal +run "apply_key_vault_integration" { + command = apply + variables { + environment = merge(var.environment, { app_name = "appcs-kv" }) + tags = var.tags + use_case = var.use_case + resource_group_name = run.setup.resource_group_name + virtual_network = run.setup.virtual_network + private_dns_zone_resource_group_name = run.setup.private_dns_zone_resource_group_name + subnet_pep_id = run.setup.subnet_pep_id + size = null + subscription_id = run.setup.subscription_id + key_vaults = [ + { + name = run.setup.key_vaults[0].name + resource_group_name = run.setup.key_vaults[0].resource_group_name + has_rbac_support = run.setup.key_vaults[0].has_rbac_support + app_principal_ids = [run.setup.managed_identity_principal_id] + } + ] + authorized_teams = { + writers = [] + readers = [] + } + } + + assert { + condition = azurerm_app_configuration.this.identity[0].principal_id != null + error_message = "Managed identity principal id must be present for KV integration" + } + assert { + condition = length(module.app_roles) == 1 + error_message = "One app_roles module must be created for the App Configuration identity" + } + assert { + condition = length(local.app_assignments) == 1 + error_message = "App Configuration identity must be assigned to Key Vault" + } +} diff --git a/infra/modules/azure_app_configuration/tests/setup/README.md b/infra/modules/azure_app_configuration/tests/setup/README.md new file mode 100644 index 0000000000..a46df95900 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/setup/README.md @@ -0,0 +1,53 @@ +# setup + + +## Requirements + +| Name | Version | +|------|---------| +| [azurerm](#requirement\_azurerm) | ~> 4.0 | +| [dx](#requirement\_dx) | ~> 0.8 | +| [random](#requirement\_random) | ~> 3.7 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azurerm_key_vault.kv](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/key_vault) | resource | +| [azurerm_private_endpoint.kv](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/private_endpoint) | resource | +| [azurerm_resource_group.sut](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group) | resource | +| [random_integer.appcs_kv_instance](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/integer) | resource | +| [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) | data source | +| [azurerm_private_dns_zone.appcs](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/private_dns_zone) | data source | +| [azurerm_private_dns_zone.kv](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/private_dns_zone) | data source | +| [azurerm_resource_group.network](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/resource_group) | data source | +| [azurerm_resource_group.test](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/resource_group) | data source | +| [azurerm_subnet.pep](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/subnet) | data source | +| [azurerm_user_assigned_identity.test](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/user_assigned_identity) | data source | +| [azurerm_virtual_network.vnet](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/virtual_network) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [environment](#input\_environment) | n/a |
object({
prefix = string
env_short = string
location = string
domain = optional(string)
app_name = string
instance_number = string
})
| n/a | yes | +| [tags](#input\_tags) | Tags to apply to setup resources | `map(string)` | n/a | yes | +| [test\_kind](#input\_test\_kind) | A value between integration and e2e | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [key\_vaults](#output\_key\_vaults) | n/a | +| [managed\_identity\_principal\_id](#output\_managed\_identity\_principal\_id) | n/a | +| [private\_dns\_zone\_appcs](#output\_private\_dns\_zone\_appcs) | n/a | +| [private\_dns\_zone\_resource\_group\_name](#output\_private\_dns\_zone\_resource\_group\_name) | n/a | +| [resource\_group\_name](#output\_resource\_group\_name) | n/a | +| [subnet\_pep\_id](#output\_subnet\_pep\_id) | n/a | +| [subscription\_id](#output\_subscription\_id) | n/a | +| [virtual\_network](#output\_virtual\_network) | n/a | + diff --git a/infra/modules/azure_app_configuration/tests/setup/main.tf b/infra/modules/azure_app_configuration/tests/setup/main.tf new file mode 100644 index 0000000000..49ba779e22 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/setup/main.tf @@ -0,0 +1,147 @@ +locals { + naming_config = { + prefix = var.environment.prefix, + environment = var.environment.env_short, + location = var.environment.location, + domain = var.environment.domain, + name = var.environment.app_name, + instance_number = tonumber(var.environment.instance_number), + } + + existing_resources = { + prefix = var.environment.prefix, + environment = var.environment.env_short, + location = var.environment.location, + domain = "" + name = var.test_kind, + instance_number = tonumber(var.environment.instance_number), + } +} + +data "azurerm_client_config" "current" {} + +data "azurerm_user_assigned_identity" "test" { + name = provider::dx::resource_name(merge(local.existing_resources, { domain = "devex", resource_type = "managed_identity" })) + resource_group_name = provider::dx::resource_name(merge(local.existing_resources, { name = "devex", resource_type = "resource_group" })) +} + +data "azurerm_private_dns_zone" "kv" { + name = "privatelink.vaultcore.azure.net" + resource_group_name = data.azurerm_resource_group.network.name +} + +data "azurerm_resource_group" "test" { + name = provider::dx::resource_name(merge(local.existing_resources, { resource_type = "resource_group" })) +} + +data "azurerm_resource_group" "network" { + name = provider::dx::resource_name(merge(local.existing_resources, { resource_type = "resource_group", name = "network" })) +} + +data "azurerm_virtual_network" "vnet" { + name = provider::dx::resource_name(merge(local.existing_resources, { resource_type = "virtual_network" })) + resource_group_name = data.azurerm_resource_group.test.name +} + +data "azurerm_subnet" "pep" { + name = provider::dx::resource_name(merge(local.existing_resources, { resource_type = "subnet", name = "pep" })) + resource_group_name = data.azurerm_resource_group.test.name + virtual_network_name = data.azurerm_virtual_network.vnet.name +} + +data "azurerm_private_dns_zone" "appcs" { + name = "privatelink.azconfig.io" + resource_group_name = data.azurerm_resource_group.network.name + tags = var.tags +} + +resource "azurerm_resource_group" "sut" { + name = provider::dx::resource_name(merge(local.naming_config, { resource_type = "resource_group" })) + location = var.environment.location + tags = var.tags +} + +resource "random_integer" "appcs_kv_instance" { + min = 1 + max = 99 +} + +resource "azurerm_key_vault" "kv" { + name = provider::dx::resource_name(merge(local.naming_config, { resource_type = "key_vault", domain = "int", instance_number = random_integer.appcs_kv_instance.result })) + location = azurerm_resource_group.sut.location + resource_group_name = azurerm_resource_group.sut.name + tenant_id = data.azurerm_client_config.current.tenant_id + rbac_authorization_enabled = true + sku_name = "standard" + purge_protection_enabled = true + soft_delete_retention_days = 7 + public_network_access_enabled = false + + network_acls { + bypass = "AzureServices" + default_action = "Deny" + } + + tags = var.tags +} + +resource "azurerm_private_endpoint" "kv" { + name = provider::dx::resource_name(merge(local.naming_config, { resource_type = "key_vault_private_endpoint", domain = "int", instance_number = random_integer.appcs_kv_instance.result })) + location = azurerm_resource_group.sut.location + resource_group_name = azurerm_resource_group.sut.name + subnet_id = data.azurerm_subnet.pep.id + + private_service_connection { + name = provider::dx::resource_name(merge(local.naming_config, { resource_type = "key_vault_private_endpoint", domain = "int", instance_number = random_integer.appcs_kv_instance.result })) + private_connection_resource_id = azurerm_key_vault.kv.id + is_manual_connection = false + subresource_names = ["vault"] + } + + private_dns_zone_group { + name = "private-dns-zone-group" + private_dns_zone_ids = [data.azurerm_private_dns_zone.kv.id] + } + + tags = var.tags +} + +output "resource_group_name" { + value = azurerm_resource_group.sut.name +} + +output "subnet_pep_id" { + value = data.azurerm_subnet.pep.id +} + +output "private_dns_zone_resource_group_name" { + value = data.azurerm_resource_group.network.name +} + +output "private_dns_zone_appcs" { + value = data.azurerm_private_dns_zone.appcs.id +} + +output "virtual_network" { + value = { + name = data.azurerm_virtual_network.vnet.name + resource_group_name = data.azurerm_resource_group.network.name + } +} + +output "key_vaults" { + value = [{ + name = azurerm_key_vault.kv.name + resource_group_name = azurerm_key_vault.kv.resource_group_name + has_rbac_support = true + app_principal_ids = [] # Empty in setup, will be filled by test scenario with actual principal IDs + }] +} + +output "subscription_id" { + value = data.azurerm_client_config.current.subscription_id +} + +output "managed_identity_principal_id" { + value = data.azurerm_user_assigned_identity.test.principal_id +} diff --git a/infra/modules/azure_app_configuration/tests/setup/providers.tf b/infra/modules/azure_app_configuration/tests/setup/providers.tf new file mode 100644 index 0000000000..b47276d012 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/setup/providers.tf @@ -0,0 +1,35 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + dx = { + source = "pagopa-dx/azure" + version = "~> 0.8" + } + random = { + source = "hashicorp/random" + version = "~> 3.7" + } + } +} + +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = true + recover_soft_deleted_key_vaults = false + } + app_configuration { + purge_soft_delete_on_destroy = true + recover_soft_deleted = true + } + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +provider "random" { +} diff --git a/infra/modules/azure_app_configuration/tests/setup/variables.tf b/infra/modules/azure_app_configuration/tests/setup/variables.tf new file mode 100644 index 0000000000..c8e0f65536 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/setup/variables.tf @@ -0,0 +1,25 @@ +variable "environment" { + type = object({ + prefix = string + env_short = string + location = string + domain = optional(string) + app_name = string + instance_number = string + }) +} + +variable "tags" { + type = map(string) + description = "Tags to apply to setup resources" +} + +variable "test_kind" { + type = string + description = "A value between integration and e2e" + + validation { + condition = contains(["integration", "e2e"], var.test_kind) + error_message = "The test_kind variable must be either 'integration' or 'e2e'." + } +} diff --git a/infra/modules/azure_app_configuration/tests/unit.tftest.hcl b/infra/modules/azure_app_configuration/tests/unit.tftest.hcl new file mode 100644 index 0000000000..db5b94c8d5 --- /dev/null +++ b/infra/modules/azure_app_configuration/tests/unit.tftest.hcl @@ -0,0 +1,217 @@ +variables { + environment = { + prefix = "dx" + env_short = "d" + location = "italynorth" + domain = "modules" + app_name = "appcs" + instance_number = "01" + } + + tags = { + BusinessUnit = "DevEx" + CostCenter = "TS000 - Tecnologia e Servizi" + CreatedBy = "Terraform" + Environment = "Dev" + Source = "https://github.com/pagopa/dx/infra/modules/azure_app_configuration/tests" + ManagementTeam = "Developer Experience" + Test = "true" + TestName = "App Configuration unit tests" + } + + use_case = "default" + + resource_group_name = "rg-test" + virtual_network = { + name = "vnet-test" + resource_group_name = "rg-network" + } + private_dns_zone_resource_group_name = null + subnet_pep_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-test/providers/Microsoft.Network/virtualNetworks/vnet-test/subnets/snet-pep" + + size = null + key_vaults = null + authorized_teams = { + writers = [] + readers = [] + } + subscription_id = "00000000-0000-0000-0000-000000000000" +} + +mock_provider "azurerm" {} + +// Override DNS zone data source with a deterministic ID so plan can succeed +override_data { + target = data.azurerm_private_dns_zone.appconfig + values = { + id = "/subscriptions/12345678-1234-9876-4563-123456789012/resourceGroups/rg-network/providers/Microsoft.Network/privateDnsZones/privatelink.azconfig.io" + name = "privatelink.azconfig.io" + } +} + +run "app_configuration_basics" { + command = plan + + assert { + condition = azurerm_app_configuration.this.sku == "standard" + error_message = "SKU must resolve to 'standard' when use_case=default and size unset" + } + assert { + condition = azurerm_app_configuration.this.identity[0].type == "SystemAssigned" + error_message = "Identity must be SystemAssigned" + } + assert { + condition = azurerm_app_configuration.this.local_auth_enabled == false + error_message = "Local auth must be disabled" + } + assert { + condition = azurerm_app_configuration.this.public_network_access == "Disabled" + error_message = "Public network access must be Disabled" + } + assert { + condition = azurerm_app_configuration.this.purge_protection_enabled == true + error_message = "Purge protection must be enabled" + } + assert { + condition = azurerm_app_configuration.this.data_plane_proxy_authentication_mode == "Pass-through" + error_message = "Data plane proxy authentication mode must be Pass-through" + } +} + +run "app_configuration_explicit_premium" { + command = plan + variables { size = "premium" } + + assert { + condition = azurerm_app_configuration.this.sku == "premium" + error_message = "SKU must be 'premium' when size is explicitly set" + } +} + +run "app_configuration_development_use_case" { + command = plan + variables { + use_case = "development" + } + + assert { + condition = azurerm_app_configuration.this.sku == "developer" + error_message = "SKU must be 'developer' when use_case is 'development'" + } +} + + +run "private_endpoint_configuration_stores" { + command = plan + + assert { + condition = azurerm_private_endpoint.app_config.subnet_id == var.subnet_pep_id + error_message = "Private Endpoint must target provided PEP subnet" + } + assert { + condition = azurerm_private_endpoint.app_config.private_service_connection[0].subresource_names[0] == "configurationStores" + error_message = "Private Endpoint subresource must be 'configurationStores'" + } + assert { + condition = can(regex("privatelink\\.azconfig\\.io$", data.azurerm_private_dns_zone.appconfig.name)) + error_message = "DNS zone name must match privatelink.azconfig.io" + } +} + +run "private_endpoint_subresource" { + command = plan + assert { + condition = azurerm_private_endpoint.app_config.private_service_connection[0].subresource_names[0] == "configurationStores" + error_message = "Private endpoint subresource must be configurationStores" + } +} + +run "key_vault_integration" { + command = plan + variables { + key_vaults = [ + { + name = "kv-test" + resource_group_name = "rg-kv" + has_rbac_support = true + app_principal_ids = ["11111111-1111-1111-1111-111111111111"] + } + ] + } + + override_data { + target = module.app_roles["11111111-1111-1111-1111-111111111111"].module.key_vault.data.azurerm_client_config.current + values = { + tenant_id = "00000000-0000-0000-0000-000000000000" + } + } + + assert { + condition = length(module.app_roles) == 1 + error_message = "One app_roles module instance must be created for the principal" + } +} + +run "key_vault_multiple_principals" { + command = plan + variables { + key_vaults = [ + { + name = "kv-test-1" + resource_group_name = "rg-kv" + has_rbac_support = true + app_principal_ids = ["11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"] + }, + { + name = "kv-test-2" + resource_group_name = "rg-kv" + has_rbac_support = false + app_principal_ids = ["11111111-1111-1111-1111-111111111111"] + } + ] + } + + override_data { + target = module.app_roles["11111111-1111-1111-1111-111111111111"].module.key_vault.data.azurerm_client_config.current + values = { + tenant_id = "00000000-0000-0000-0000-000000000000" + } + } + + override_data { + target = module.app_roles["22222222-2222-2222-2222-222222222222"].module.key_vault.data.azurerm_client_config.current + values = { + tenant_id = "00000000-0000-0000-0000-000000000000" + } + } + + assert { + condition = length(module.app_roles) == 2 + error_message = "Two app_roles module instances must be created (one per distinct principal)" + } + + assert { + condition = length(local.app_assignments) == 2 + error_message = "Two distinct principals must be identified" + } +} + +run "authorized_teams_roles" { + command = plan + variables { + authorized_teams = { + writers = ["33333333-3333-3333-3333-333333333333"] + readers = ["44444444-4444-4444-4444-444444444444", "55555555-5555-5555-5555-555555555555"] + } + } + + assert { + condition = length(module.appconfig_team_roles) == 3 + error_message = "Three team role assignments must be created (1 writer + 2 readers)" + } + + assert { + condition = length(local.appconfig_role_assignments) == 3 + error_message = "Three role assignments must be in local map" + } +} diff --git a/infra/modules/azure_app_configuration/variables.tf b/infra/modules/azure_app_configuration/variables.tf new file mode 100644 index 0000000000..737aaca826 --- /dev/null +++ b/infra/modules/azure_app_configuration/variables.tf @@ -0,0 +1,101 @@ +variable "tags" { + type = map(any) + description = "A map of tags to assign to the resources." +} + +variable "environment" { + type = object({ + prefix = string + env_short = string + location = string + domain = optional(string) + app_name = string + instance_number = string + }) + + description = "Values which are used to generate resource names and location short names. They are all mandatory except for domain, which should not be used only in the case of a resource used by multiple domains." +} + +variable "resource_group_name" { + type = string + description = "The name of the resource group where resources will be deployed." +} + +variable "use_case" { + type = string + description = "Allowed values: 'default', 'development'." + default = "default" + + validation { + condition = contains(["default", "development"], var.use_case) + error_message = "Allowed values for \"use_case\" are \"default\", \"development\"." + } +} + +variable "size" { + type = string + default = null + description = <<-EOT + "App Configuration SKU. Allowed values: 'standard', 'premium'. If not set, it will be determined by the use_case." + EOT + + validation { + condition = var.size == null || (contains(["standard", "premium"], var.size) && var.use_case == "default") + error_message = "Allowed values: 'standard', 'premium'. For development purpose, set \"use_case\" to \"development\" and unset this." + } +} + +variable "virtual_network" { + type = object({ + name = string + resource_group_name = string + }) + description = "Virtual network where the subnet will be created." +} + +variable "private_dns_zone_resource_group_name" { + type = string + default = null + description = "Name of the resource group containing the private DNS zone for private endpoints. Default is the resource group of the virtual network." +} + +variable "subnet_pep_id" { + type = string + description = "ID of the subnet hosting private endpoints." +} + +variable "subscription_id" { + type = string + description = "Subscription Id of the involved resources" +} + +variable "key_vaults" { + type = list(object({ + name = string + resource_group_name = string + has_rbac_support = bool + app_principal_ids = list(string) + })) + default = null + + description = <