From 4543cb267af89dfece1abcdf95ee87d41d41a560 Mon Sep 17 00:00:00 2001 From: Josiel Souza Date: Thu, 26 Feb 2026 10:32:41 +0000 Subject: [PATCH] feat(iac): add bootstrap Bicep and Terraform make targets for mbsgw Adds environment-specific variables, Bicep modules for resource group initialisation, and a Makefile target to automate deployment. Also includes gitleaks annotations for role IDs. Refs: DTOSS-12260 --- Makefile | 1 + infrastructure/environments/dev/variables.sh | 8 + .../environments/preprod/variables.sh | 8 + infrastructure/environments/prod/variables.sh | 8 + .../environments/review/variables.sh | 8 + .../terraform/resource_group_init/core.bicep | 96 ++++++++ .../terraform/resource_group_init/dns.bicep | 14 ++ .../resource_group_init/keyVault.bicep | 75 ++++++ .../terraform/resource_group_init/main.bicep | 213 ++++++++++++++++++ .../resource_group_init/managedIdentity.bicep | 16 ++ .../resource_group_init/privateEndpoint.bicep | 71 ++++++ .../resource_group_init/storage.bicep | 94 ++++++++ scripts/bash/resource_group_init.sh | 51 +++++ scripts/terraform/terraform.mk | 68 ++++++ 14 files changed, 731 insertions(+) create mode 100644 infrastructure/environments/dev/variables.sh create mode 100644 infrastructure/environments/preprod/variables.sh create mode 100644 infrastructure/environments/prod/variables.sh create mode 100644 infrastructure/environments/review/variables.sh create mode 100644 infrastructure/terraform/resource_group_init/core.bicep create mode 100644 infrastructure/terraform/resource_group_init/dns.bicep create mode 100644 infrastructure/terraform/resource_group_init/keyVault.bicep create mode 100644 infrastructure/terraform/resource_group_init/main.bicep create mode 100644 infrastructure/terraform/resource_group_init/managedIdentity.bicep create mode 100644 infrastructure/terraform/resource_group_init/privateEndpoint.bicep create mode 100644 infrastructure/terraform/resource_group_init/storage.bicep create mode 100755 scripts/bash/resource_group_init.sh create mode 100644 scripts/terraform/terraform.mk diff --git a/Makefile b/Makefile index a2d0761c..068f7283 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ include scripts/shared.mk +include scripts/terraform/terraform.mk .DEFAULT_GOAL := help diff --git a/infrastructure/environments/dev/variables.sh b/infrastructure/environments/dev/variables.sh new file mode 100644 index 00000000..4219024c --- /dev/null +++ b/infrastructure/environments/dev/variables.sh @@ -0,0 +1,8 @@ +ENV_CONFIG=dev +ENVIRONMENT=dev +AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - Dev" +HUB_SUBSCRIPTION="Digital Screening DToS - DevOps" +HUB=dev +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false +ADO_MANAGEMENT_POOL=private-pool-dev-uks diff --git a/infrastructure/environments/preprod/variables.sh b/infrastructure/environments/preprod/variables.sh new file mode 100644 index 00000000..044cf37e --- /dev/null +++ b/infrastructure/environments/preprod/variables.sh @@ -0,0 +1,8 @@ +ENV_CONFIG=preprod +ENVIRONMENT=preprod +AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - Preprod" +HUB_SUBSCRIPTION="Digital Screening DToS - Core Services Prod Hub" +HUB=prod +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false +ADO_MANAGEMENT_POOL=private-pool-prod-uks diff --git a/infrastructure/environments/prod/variables.sh b/infrastructure/environments/prod/variables.sh new file mode 100644 index 00000000..4f679115 --- /dev/null +++ b/infrastructure/environments/prod/variables.sh @@ -0,0 +1,8 @@ +ENV_CONFIG=prod +ENVIRONMENT=prod +AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - Prod" +HUB_SUBSCRIPTION="Digital Screening DToS - Core Services Prod Hub" +HUB=prod +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false +ADO_MANAGEMENT_POOL=private-pool-prod-uks diff --git a/infrastructure/environments/review/variables.sh b/infrastructure/environments/review/variables.sh new file mode 100644 index 00000000..e9c6e841 --- /dev/null +++ b/infrastructure/environments/review/variables.sh @@ -0,0 +1,8 @@ +ENV_CONFIG=review +ENVIRONMENT=review +AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - Dev" +HUB_SUBSCRIPTION="Digital Screening DToS - DevOps" +HUB=dev +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false +ADO_MANAGEMENT_POOL=private-pool-dev-uks diff --git a/infrastructure/terraform/resource_group_init/core.bicep b/infrastructure/terraform/resource_group_init/core.bicep new file mode 100644 index 00000000..a0e22e4d --- /dev/null +++ b/infrastructure/terraform/resource_group_init/core.bicep @@ -0,0 +1,96 @@ +targetScope='subscription' + +@minLength(1) +param miPrincipalId string +@minLength(1) +param miName string +@minLength(1) +param userGroupPrincipalID string +@minLength(1) +param userGroupName string + +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleID = { + contributor: 'b24988ac-6180-42a0-ab88-20f7382dd24c' + kvSecretsUser: '4633458b-17de-408a-b874-0445c86b69e6' // gitleaks:allow + kvSecretsOfficer: 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7' // gitleaks:allow + rbacAdmin: 'f58310d9-a9f6-439a-9e8d-f62e7b41a168' + storageBlobDataContributor: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + storageQueueDataContributor: '974c5e8b-45b9-4653-ba55-5f855dd0fb88' + AzureConnectedMachineOnboarding: 'b64e21ea-ac4e-4cdf-9dc9-5b892992bee7' +} + +// Define role assignments for managed identity +var miRoleAssignments = [ + { + roleName: 'contributor' + roleId: roleID.contributor + description: 'Contributor access to subscription' + } + { + roleName: 'kvSecretsUser' + roleId: roleID.kvSecretsUser + description: 'kvSecretsUser access to subscription' + } + { + roleName: 'rbacAdmin' + roleId: roleID.rbacAdmin + description: 'RBAC Administrator. Restricted to only assign/remove: Storage Blob Data Contributor, Storage Queue Data Contributor, and Azure Connected Machine Onboarding.' + // Delegated RBAC: This condition restricts the RBAC Administrator to only manage specific roles. + // This is a security best practice that prevents the identity from granting itself or others sensitive roles like 'Owner' or 'User Access Administrator'. + condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}}))' + conditionVersion: '2.0' + } +] + +// Define role assignments for Entra ID group +var groupRoleAssignments = [ + { + roleName: 'contributor' + roleId: roleID.contributor + description: 'Contributor access to subscription' + } + { + roleName: 'kvSecretsOfficer' + roleId: roleID.kvSecretsOfficer + description: 'kvSecretsOfficer access to subscription' + } + { + roleName: 'rbacAdmin' + roleId: roleID.rbacAdmin + description: 'RBAC Administrator. Restricted to only assign/remove: Storage Blob Data Contributor, Storage Queue Data Contributor, and Azure Connected Machine Onboarding.' + // Delegated RBAC: This condition restricts the RBAC Administrator to only manage specific roles. + // This is a security best practice that prevents the identity from granting itself or others sensitive roles like 'Owner' or 'User Access Administrator'. + condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}}))' + conditionVersion: '2.0' + } +] + +// This creates one resource for each item in the miRoleAssignments array +resource miRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in miRoleAssignments: { + // guid() ensures unique names for each assignment + name: guid(subscription().subscriptionId, miPrincipalId, role.roleName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: miPrincipalId + principalType: 'ServicePrincipal' + description: '${miName} ${role.description}' + // Conditionally include the 'condition' property only if it exists in the role object + condition: role.?condition + conditionVersion: role.?conditionVersion + } +}] + + +// This creates one resource for each item in the groupRoleAssignments array +resource groupRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in groupRoleAssignments: { + name: guid(subscription().subscriptionId, userGroupPrincipalID, role.roleName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: userGroupPrincipalID + principalType: 'Group' + description: '${userGroupName} ${role.description}' + condition: role.?condition + conditionVersion: role.?conditionVersion + } +}] diff --git a/infrastructure/terraform/resource_group_init/dns.bicep b/infrastructure/terraform/resource_group_init/dns.bicep new file mode 100644 index 00000000..31896604 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/dns.bicep @@ -0,0 +1,14 @@ +param resourceServiceType string + +var dnsZoneName = { + storage: 'privatelink.blob.${environment().suffixes.storage}' + // Cannot read vault URL from environment() because of https://github.com/Azure/bicep/issues/9839 + keyVault: 'privatelink.vaultcore.azure.net' +} + +// Retrieve the private DNS zone for storage accounts +resource privateDNSZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = { + name: dnsZoneName[resourceServiceType] +} + +output privateDNSZoneID string = privateDNSZone.id diff --git a/infrastructure/terraform/resource_group_init/keyVault.bicep b/infrastructure/terraform/resource_group_init/keyVault.bicep new file mode 100644 index 00000000..ce8655ff --- /dev/null +++ b/infrastructure/terraform/resource_group_init/keyVault.bicep @@ -0,0 +1,75 @@ + +param enableSoftDelete bool +param keyVaultName string +param miPrincipalId string +param miName string +param region string +param userGroupPrincipalID string +param userGroupName string + +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleID = { + kvSecretsUser: '4633458b-17de-408a-b874-0445c86b69e6' // gitleaks:allow + kvSecretsOfficer: 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7' // gitleaks:allow +} + +// Define role assignments for managed identity (read-only) +var miRoleAssignments = [ + { + roleName: 'kvSecretsUser' + roleId: roleID.kvSecretsUser + description: 'kvSecretsUser access to resource group' + } +] + +// Define role assignments for Entra ID group (full management) +var groupRoleAssignments = [ + { + roleName: 'kvSecretsOfficer' + roleId: roleID.kvSecretsOfficer + description: 'kvSecretsOfficer access to resource group' + } +] + +resource keyVault 'Microsoft.KeyVault/vaults@2024-11-01' = { + name: keyVaultName + location: region + properties: { + tenantId: subscription().tenantId + sku: { + name: 'standard' + family: 'A' + } + enableRbacAuthorization: true + enabledForDeployment: true + enabledForTemplateDeployment: true + enabledForDiskEncryption: true + enableSoftDelete: enableSoftDelete + publicNetworkAccess: 'disabled' + } +} + +// Managed identity RBAC assignments using loop +resource miRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in miRoleAssignments: { + name: guid(subscription().subscriptionId, miPrincipalId, role.roleName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: miPrincipalId + principalType: 'ServicePrincipal' + description: '${miName} ${role.description}' + } +}] + +// Entra ID Group RBAC assignments using loop +resource groupRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in groupRoleAssignments: { + name: guid(subscription().subscriptionId, userGroupPrincipalID, role.roleName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: userGroupPrincipalID + principalType: 'Group' + description: '${userGroupName} ${role.description}' + } +}] + +// Output the key vault ID so it can be used to create the private endpoint +output keyVaultID string = keyVault.id diff --git a/infrastructure/terraform/resource_group_init/main.bicep b/infrastructure/terraform/resource_group_init/main.bicep new file mode 100644 index 00000000..ec56a3b7 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/main.bicep @@ -0,0 +1,213 @@ +targetScope='subscription' + +param enableSoftDelete bool +param envConfig string +param region string +param storageAccountRGName string +param storageAccountName string +param appShortName string +param userGroupPrincipalID string + +var hubMap = { + dev: 'dev' + int: 'dev' + review: 'dev' + nft: 'dev' + preprod: 'prod' + prod: 'prod' +} +var privateEndpointRGName = 'rg-hub-${envConfig}-uks-hub-private-endpoints' +var privateDNSZoneRGName = 'rg-hub-${hubMap[envConfig]}-uks-private-dns-zones' +var managedIdentityRGName = 'rg-mi-${envConfig}-uks' +var infraResourceGroupName = 'rg-mbsgw-${envConfig}-infra' +var keyVaultName = 'kv-mbsgw-${envConfig}-inf' + +var miADOtoAZname = 'mi-${appShortName}-${envConfig}-adotoaz-uks' +var miGHtoADOname = 'mi-${appShortName}-${envConfig}-ghtoado-uks' +var userGroupName = 'screening_${appShortName}_${envConfig}' + +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleID = { + kvSecretsUser: '4633458b-17de-408a-b874-0445c86b69e6' // gitleaks:allow + monitoringContributor: '749f88d5-cbae-40b8-bcfc-e573ddc772fa' + networkContributor: '4d97b98b-1d4f-4787-a291-c67834d212e7' + reader: 'acdd72a7-3385-48ef-bd42-f606fba81ae7' +} + +// Retrieve existing terraform state resource group +resource storageAccountRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: storageAccountRGName +} +// Retrieve existing private endpoint resource group +resource privateEndpointResourceGroup 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: privateEndpointRGName +} +// Retrieve existing private DNS zone resource group +resource privateDNSZoneRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: privateDNSZoneRGName +} +// Retrieve existing managed identity resource group +resource managedIdentityRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: managedIdentityRGName +} + +// Create the managed identity assumed by Azure devops to connect to Azure +module managedIdentiyADOtoAZ 'managedIdentity.bicep' = { + scope: managedIdentityRG + params: { + name: miADOtoAZname + region: region + } +} + +// Create the managed identity assumed by Github actions to trigger Azure devops pipelines +module managedIdentiyGHtoADO 'managedIdentity.bicep' = { + scope: managedIdentityRG + params: { + name: miGHtoADOname + fedCredProperties: { + audiences: [ 'api://AzureADTokenExchange' ] + issuer: 'https://token.actions.githubusercontent.com' + subject: 'repo:NHSDigital/manage-breast-screening-gateway:environment:${envConfig}' + } + region: region + } +} + + +// Let the GHtoADO managed identity access a subscription +resource readerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, envConfig, 'reader') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.reader) + principalId: managedIdentiyGHtoADO.outputs.miPrincipalID + principalType: 'ServicePrincipal' + description: '${miGHtoADOname} Reader access to subscription' + } +} + +// Create the storage account, blob service and container +module terraformStateStorageAccount 'storage.bicep' = { + scope: storageAccountRG + params: { + storageLocation: region + storageName: storageAccountName + enableSoftDelete: enableSoftDelete + miPrincipalID: managedIdentiyADOtoAZ.outputs.miPrincipalID + miName: miADOtoAZname + userGroupPrincipalID: userGroupPrincipalID + userGroupName: userGroupName + } +} + +// Retrieve storage private DNS zone +module storagePrivateDNSZone 'dns.bicep' = { + scope: privateDNSZoneRG + params: { + resourceServiceType: 'storage' + } +} + +// Retrieve key vault private DNS zone +module keyVaultPrivateDNSZone 'dns.bicep' = { + scope: privateDNSZoneRG + params: { + resourceServiceType: 'keyVault' + } +} + +// Create private endpoint and register DNS +module storageAccountPrivateEndpoint 'privateEndpoint.bicep' = { + scope: privateEndpointResourceGroup + params: { + hub: hubMap[envConfig] + region: region + name: storageAccountName + resourceServiceType: 'storage' + resourceID: terraformStateStorageAccount.outputs.storageAccountID + privateDNSZoneID: storagePrivateDNSZone.outputs.privateDNSZoneID + } +} + +// Let the managed identity manage monitoring resources (Application Insights, Log Analytics) +resource monitoringContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, envConfig, 'monitoringContributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.monitoringContributor) + principalId: managedIdentiyADOtoAZ.outputs.miPrincipalID + principalType: 'ServicePrincipal' + description: '${miADOtoAZname} Monitoring Contributor access to subscription' + } +} + +// Let the managed identity configure vnet peering and DNS records +resource networkContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, envConfig, 'networkContributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.networkContributor) + principalId: managedIdentiyADOtoAZ.outputs.miPrincipalID + principalType: 'ServicePrincipal' + description: '${miADOtoAZname} Network Contributor access to subscription' + } +} + +// Create infra resource group +resource infraRG 'Microsoft.Resources/resourceGroups@2024-11-01' = { + name: infraResourceGroupName + location: region +} + +// Private endpoint for infra key vault +module kvPrivateEndpoint 'privateEndpoint.bicep' = { + scope: resourceGroup(infraResourceGroupName) + params: { + hub: hubMap[envConfig] + region: region + name: keyVaultName + resourceServiceType: 'keyVault' + resourceID: keyVaultModule.outputs.keyVaultID + privateDNSZoneID: keyVaultPrivateDNSZone.outputs.privateDNSZoneID + } +} + +// Use a module to deploy Key Vault into the infra RG +module keyVaultModule 'keyVault.bicep' = { + name: 'keyVaultDeployment' + scope: resourceGroup(infraResourceGroupName) + params: { + enableSoftDelete : enableSoftDelete + keyVaultName: keyVaultName + miName: miADOtoAZname + miPrincipalId: managedIdentiyADOtoAZ.outputs.miPrincipalID + region: region + userGroupPrincipalID: userGroupPrincipalID + userGroupName: userGroupName + } +} + +// Let the Entra ID group manage monitoring resources (Application Insights, Log Analytics) +resource groupMonitoringContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, userGroupName, 'monitoringContributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.monitoringContributor) + principalId: userGroupPrincipalID + principalType: 'Group' + description: '${userGroupName} Monitoring Contributor access to subscription' + } +} + +// Let the Entra ID group configure vnet peering and DNS records +resource groupNetworkContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, userGroupName, 'networkContributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.networkContributor) + principalId: userGroupPrincipalID + principalType: 'Group' + description: '${userGroupName} Network Contributor access to subscription' + } +} + +output miPrincipalID string = managedIdentiyADOtoAZ.outputs.miPrincipalID +output miName string = miADOtoAZname +output keyVaultPrivateDNSZone string = keyVaultPrivateDNSZone.outputs.privateDNSZoneID +output storagePrivateDNSZone string = storagePrivateDNSZone.outputs.privateDNSZoneID diff --git a/infrastructure/terraform/resource_group_init/managedIdentity.bicep b/infrastructure/terraform/resource_group_init/managedIdentity.bicep new file mode 100644 index 00000000..d0deea68 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/managedIdentity.bicep @@ -0,0 +1,16 @@ +param name string +param region string +param fedCredProperties object = {} + +resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = { + location: region + name: name +} + +resource managedIdentiyGHtoADOFedCred 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2024-11-30' = if (!empty(fedCredProperties)) { + parent: mi + name: 'github-actions' + properties: fedCredProperties +} + +output miPrincipalID string = mi.properties.principalId diff --git a/infrastructure/terraform/resource_group_init/privateEndpoint.bicep b/infrastructure/terraform/resource_group_init/privateEndpoint.bicep new file mode 100644 index 00000000..a6e96a29 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/privateEndpoint.bicep @@ -0,0 +1,71 @@ +param hub string +param region string +param privateDNSZoneID string +param name string +param resourceID string +param resourceServiceType string + +var RGName = 'rg-hub-${hub}-uks-hub-networking' +var vnetName = 'VNET-${toUpper(hub)}-UKS-HUB' +var subnetName = 'SN-${toUpper(hub)}-UKS-HUB-pep' + +var groupID = { + storage: 'blob' + keyVault: 'vault' +} + +// Retrieve the existing vnet resource group +resource vnetRG 'Microsoft.Resources/resourceGroups@2024-11-01' existing = { + name: RGName + scope: subscription() +} + +// Retrieve the existing vnet +resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' existing = { + name: vnetName + scope: vnetRG +} + +// Retrieve the existing Subnet within the vnet +resource subnet 'Microsoft.Network/virtualNetworks/subnets@2024-01-01' existing = { + parent: vnet + name: subnetName +} + +// Create the private endpoint for the storage account +resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-01-01' = { + name: '${name}-pep' + location: region + properties: { + subnet: { + id: subnet.id + } + privateLinkServiceConnections: [ + { + name: '${name}-connection' + properties: { + privateLinkServiceId: resourceID + groupIds: [ + groupID[resourceServiceType] + ] + } + } + ] + } +} + +// Register the private endpoint in the private DNS zone +resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = { + parent: privateEndpoint + name: '${name}-dns' + properties: { + privateDnsZoneConfigs: [ + { + name: '${name}-dns-zone-config' + properties: { + privateDnsZoneId: privateDNSZoneID + } + } + ] + } +} diff --git a/infrastructure/terraform/resource_group_init/storage.bicep b/infrastructure/terraform/resource_group_init/storage.bicep new file mode 100644 index 00000000..ddee67d6 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/storage.bicep @@ -0,0 +1,94 @@ +param storageLocation string +param storageName string +param enableSoftDelete bool +param miPrincipalID string +param miName string +param userGroupPrincipalID string +param userGroupName string + +// Create storage account without public access +resource storageAccount 'Microsoft.Storage/storageAccounts@2024-01-01' = { + name: storageName + location: storageLocation + sku: { + name: 'Standard_RAGRS' + } + kind: 'StorageV2' + properties: { + allowBlobPublicAccess: false + allowSharedKeyAccess: false + encryption: { + requireInfrastructureEncryption: true + } + minimumTlsVersion: 'TLS1_2' + publicNetworkAccess: 'Disabled' + } +} + + +// Create the blob service +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2024-01-01' = { + parent: storageAccount + name: 'default' + properties: { + containerDeleteRetentionPolicy: { + days: enableSoftDelete ? 15 : null + enabled: enableSoftDelete + } + deleteRetentionPolicy: { + days: enableSoftDelete ? 15 : null + enabled: enableSoftDelete + } + isVersioningEnabled: true + } +} + +// Create the blob container +resource blobContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2024-01-01' = { + parent: blobService + name: 'terraform-state' + properties: { + publicAccess: 'None' + defaultEncryptionScope: '$account-encryption-key' + denyEncryptionScopeOverride: false + } +} + +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleID = { + blobContributor: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' +} + +// Define role assignments array +var roleAssignments = [ + { + roleName: 'blobContributor' + roleId: roleID.blobContributor + description: 'Blob Contributor access to subscription' + } +] + +// Managed identity RBAC assignments using loop +resource miRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in roleAssignments: { + name: guid(subscription().subscriptionId, miPrincipalID, role.roleName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: miPrincipalID + principalType: 'ServicePrincipal' + description: '${miName} ${role.description}' + } +}] + +// Entra ID Group RBAC assignments using loop +resource groupRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in roleAssignments:{ + name: guid(subscription().subscriptionId, userGroupPrincipalID, role.roleName) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', role.roleId) + principalId: userGroupPrincipalID + principalType: 'Group' + description: '${userGroupName} ${role.description}' + } +}] + +// Output the storage account ID so it can be used to create the private endpoint +output storageAccountID string = storageAccount.id diff --git a/scripts/bash/resource_group_init.sh b/scripts/bash/resource_group_init.sh new file mode 100755 index 00000000..4f798e08 --- /dev/null +++ b/scripts/bash/resource_group_init.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -eu + +REGION="$1" +HUB_SUBSCRIPTION_ID="$2" +ENABLE_SOFT_DELETE="$3" +ENV_CONFIG="$4" +STORAGE_ACCOUNT_RG="$5" +STORAGE_ACCOUNT_NAME="$6" +APP_SHORT_NAME="$7" +ARM_SUBSCRIPTION_ID="$8" + +# Dynamic Group Lookup +userGroupName="screening_${APP_SHORT_NAME}_${ENV_CONFIG}" +echo "Fetching object id for group: $userGroupName" +userGroupPrincipalID=$(az ad group show --group "$userGroupName" --query id -o tsv) + +if [ -z "$userGroupPrincipalID" ]; then + echo "Error: Group '$userGroupName' not found in Entra ID" + exit 1 +fi + +echo "Found group Object ID: $userGroupPrincipalID" + +echo "Deploy to hub subscription $HUB_SUBSCRIPTION_ID..." +az deployment sub create --location "$REGION" --template-file infrastructure/terraform/resource_group_init/main.bicep \ + --subscription "$HUB_SUBSCRIPTION_ID" \ + --parameters enableSoftDelete="$ENABLE_SOFT_DELETE" envConfig="$ENV_CONFIG" region="$REGION" \ + storageAccountRGName="$STORAGE_ACCOUNT_RG" storageAccountName="$STORAGE_ACCOUNT_NAME" \ + appShortName="$APP_SHORT_NAME" userGroupPrincipalID="$userGroupPrincipalID" --what-if + +read -r -p "Are you sure you want to execute the deployment? (y/n): " confirm +[[ "$confirm" != "y" ]] && exit 0 + +output=$(az deployment sub create --location "$REGION" --template-file infrastructure/terraform/resource_group_init/main.bicep \ + --subscription "$HUB_SUBSCRIPTION_ID" \ + --parameters enableSoftDelete="$ENABLE_SOFT_DELETE" envConfig="$ENV_CONFIG" region="$REGION" \ + storageAccountRGName="$STORAGE_ACCOUNT_RG" storageAccountName="$STORAGE_ACCOUNT_NAME" \ + appShortName="$APP_SHORT_NAME" userGroupPrincipalID="$userGroupPrincipalID") + +echo "$output" + +echo Capture the outputs... +miName=$(echo "$output" | jq -r '.properties.outputs.miName.value') +miPrincipalID=$(echo "$output" | jq -r '.properties.outputs.miPrincipalID.value') + +echo "Deploy to core subscription $ARM_SUBSCRIPTION_ID..." +az deployment sub create --location "$REGION" --template-file infrastructure/terraform/resource_group_init/core.bicep \ + --subscription "$ARM_SUBSCRIPTION_ID" \ + --parameters miName="$miName" miPrincipalId="$miPrincipalID" \ + userGroupPrincipalID="$userGroupPrincipalID" userGroupName="$userGroupName" --confirm-with-what-if diff --git a/scripts/terraform/terraform.mk b/scripts/terraform/terraform.mk new file mode 100644 index 00000000..94a81353 --- /dev/null +++ b/scripts/terraform/terraform.mk @@ -0,0 +1,68 @@ +REGION=UK South +APP_SHORT_NAME=mbsgw +STORAGE_ACCOUNT_RG=rg-dtos-state-files + +dev: # Target the dev environment - make dev + $(eval include infrastructure/environments/dev/variables.sh) + +preprod: # Target the preprod environment - make preprod + $(eval include infrastructure/environments/preprod/variables.sh) + +prod: # Target the prod environment - make prod + $(eval include infrastructure/environments/prod/variables.sh) + +review: # Target the review environment - make review + $(eval include infrastructure/environments/review/variables.sh) + +ci: # Skip manual approvals when running in CI - make ci + $(eval AUTO_APPROVE=-auto-approve) + $(eval SKIP_AZURE_LOGIN=true) + +set-azure-account: # Set the Azure account for the environment - make set-azure-account + [ "${SKIP_AZURE_LOGIN}" != "true" ] && az account set -s ${AZURE_SUBSCRIPTION} || true + +resource-group-init: set-azure-account get-subscription-ids # Initialise the resources required by terraform - make resource-group-init + $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) + scripts/bash/resource_group_init.sh "${REGION}" "${HUB_SUBSCRIPTION_ID}" "${ENABLE_SOFT_DELETE}" "${ENV_CONFIG}" "${STORAGE_ACCOUNT_RG}" "${STORAGE_ACCOUNT_NAME}" "${APP_SHORT_NAME}" "${ARM_SUBSCRIPTION_ID}" + +get-subscription-ids: # Retrieve the hub subscription ID based on the subscription name in ${HUB_SUBSCRIPTION} - make get-subscription-ids + $(eval HUB_SUBSCRIPTION_ID=$(shell az account show --query id --output tsv --name ${HUB_SUBSCRIPTION})) + $(if ${ARM_SUBSCRIPTION_ID},,$(eval export ARM_SUBSCRIPTION_ID=$(shell az account show --query id --output tsv))) + +terraform-init-no-backend: # Initialise terraform modules only and update terraform lock file - make terraform-init-no-backend + rm -rf infrastructure/modules/dtos-devops-templates + git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_REF} \ + https://github.com/NHSDigital/dtos-devops-templates.git infrastructure/modules/dtos-devops-templates + terraform -chdir=infrastructure/terraform init -upgrade -backend=false + +terraform-init: set-azure-account get-subscription-ids # Initialise Terraform - make terraform-init + $(eval STORAGE_ACCOUNT_NAME=sa${APP_SHORT_NAME}${ENV_CONFIG}tfstate) + $(eval export ARM_USE_AZUREAD=true) + + rm -rf infrastructure/modules/dtos-devops-templates + git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_REF} \ + https://github.com/NHSDigital/dtos-devops-templates.git infrastructure/modules/dtos-devops-templates + + terraform -chdir=infrastructure/terraform init -upgrade -reconfigure \ + -backend-config=subscription_id=${HUB_SUBSCRIPTION_ID} \ + -backend-config=resource_group_name=${STORAGE_ACCOUNT_RG} \ + -backend-config=storage_account_name=${STORAGE_ACCOUNT_NAME} \ + -backend-config=key=${ENVIRONMENT}.tfstate + + $(eval export TF_VAR_app_short_name=${APP_SHORT_NAME}) + $(eval export TF_VAR_environment=${ENVIRONMENT}) + $(eval export TF_VAR_env_config=${ENV_CONFIG}) + $(eval export TF_VAR_hub=${HUB}) + $(eval export TF_VAR_hub_subscription_id=${HUB_SUBSCRIPTION_ID}) + +terraform-plan: terraform-init # Plan Terraform changes - make terraform-plan + terraform -chdir=infrastructure/terraform plan -var-file ../environments/${ENV_CONFIG}/variables.tfvars + +terraform-apply: terraform-init # Apply Terraform changes - make terraform-apply + terraform -chdir=infrastructure/terraform apply -var-file ../environments/${ENV_CONFIG}/variables.tfvars ${AUTO_APPROVE} + +terraform-destroy: terraform-init # Destroy Terraform resources - make terraform-destroy + terraform -chdir=infrastructure/terraform destroy -var-file ../environments/${ENV_CONFIG}/variables.tfvars ${AUTO_APPROVE} + +terraform-validate: terraform-init-no-backend # Validate Terraform changes - make terraform-validate + terraform -chdir=infrastructure/terraform validate