diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..fd6f4cd5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,21 @@ +name: test + +on: + workflow_dispatch: + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + environment: poc + concurrency: deploy-poc-${{ github.ref }} + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} diff --git a/.gitignore b/.gitignore index 79b21c9b..f388cc4a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__ lung_cancer_screening/assets/compiled/* !lung_cancer_screening/assets/compiled/.gitkeep +.DS_Store diff --git a/.gitleaksignore b/.gitleaksignore index cceb449a..34790311 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -1,3 +1,13 @@ # SEE: https://github.com/gitleaks/gitleaks/blob/master/README.md#gitleaksignore cd9c0efec38c5d63053dd865e5d4e207c0760d91:docs/guides/Perform_static_analysis.md:generic-api-key:37 +infrastructure/terraform/resource_group_init/core.bicep:generic-api-key:10 +infrastructure/terraform/resource_group_init/core.bicep:generic-api-key:11 +infrastructure/terraform/resource_group_init/core.bicep:generic-api-key:12 +infrastructure/terraform/resource_group_init/main.bicep:generic-api-key:29 +infrastructure/terraform/resource_group_init/main.bicep:generic-api-key:30 +infrastructure/terraform/resource_group_init/main.bicep:generic-api-key:31 +infrastructure/terraform/resource_group_init/main.bicep:generic-api-key:32 +infrastructure/terraform/resource_group_init/main.bicep:generic-api-key:33 +infrastructure/terraform/resource_group_init/storage.bicep:generic-api-key:59 +infrastructure/terraform/resource_group_init/keyVault.bicep:generic-api-key:10 diff --git a/infrastructure/environments/poc/variables.sh b/infrastructure/environments/poc/variables.sh new file mode 100644 index 00000000..7a6b4eaf --- /dev/null +++ b/infrastructure/environments/poc/variables.sh @@ -0,0 +1,4 @@ +ENVIRONMENT=poc +AZURE_SUBSCRIPTION="Lung Cancer Screening - Dev" +TERRAFORM_MODULES_REF=main +ENABLE_SOFT_DELETE=false diff --git a/infrastructure/terraform/resource_group_init/core.bicep b/infrastructure/terraform/resource_group_init/core.bicep new file mode 100644 index 00000000..20e96b10 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/core.bicep @@ -0,0 +1,47 @@ +targetScope='subscription' + +@minLength(1) +param miPrincipalId string +@minLength(1) +param miName 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' + rbacAdmin: 'f58310d9-a9f6-439a-9e8d-f62e7b41a168' + storageBlobDataContributor: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + storageQueueDataContributor: '974c5e8b-45b9-4653-ba55-5f855dd0fb88' +} + +// Let the managed identity configure resources in the subscription +resource contributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, miPrincipalId, 'contributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.contributor) + principalId: miPrincipalId + description: '${miName} Contributor access to subscription' + } +} + +// Let the managed identity read key vault secrets during terraform plan +resource kvSecretsUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, miPrincipalId, 'kvSecretsUser') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.kvSecretsUser) + principalId: miPrincipalId + description: '${miName} kvSecretsUser access to subscription' + } +} + +// Let the managed identity assign the Key Vault Secrets User role to the container app managed identity +resource rbacAdminAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, miPrincipalId, 'rbacAdmin') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.rbacAdmin) + principalId: miPrincipalId + condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.kvSecretsUser}, ${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.kvSecretsUser}, ${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}}))' + conditionVersion: '2.0' + description: '${miName} Role Based Access Control Administrator access to subscription. Can assign Key Vault Secrets User, Storage Blob Data Contributor, and Storage Queue Data Contributor roles.' + } +} 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..3207169b --- /dev/null +++ b/infrastructure/terraform/resource_group_init/keyVault.bicep @@ -0,0 +1,42 @@ + +param enableSoftDelete bool +param keyVaultName string +param miPrincipalId string +param miName string +param region string + +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleID = { + kvSecretsUser: '4633458b-17de-408a-b874-0445c86b69e6' +} + +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' + } +} + +// Let the managed identity read key vault secrets during terraform plan +resource kvSecretsUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, miPrincipalId, 'kvSecretsUser') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.kvSecretsUser) + principalId: miPrincipalId + description: '${miName} kvSecretsUser access to resource group' + } +} + +// 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..fd2c24ed --- /dev/null +++ b/infrastructure/terraform/resource_group_init/main.bicep @@ -0,0 +1,194 @@ +targetScope='subscription' + +param enableSoftDelete bool +param envConfig string +param region string +param storageAccountRGName string +param storageAccountName string +param appShortName string + +var hubMap = { + dev: 'dev' + int: 'dev' + review: 'dev' + nft: 'dev' + preprod: 'prod' + prd: '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-lungcs-${envConfig}-infra' +var keyVaultName = 'kv-lungcs-${envConfig}-inf' + +var miADOtoAZname = 'mi-${appShortName}-${envConfig}-adotoaz-uks' +var miGHtoADOname = 'mi-${appShortName}-${envConfig}-ghtoado-uks' + +// See: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles +var roleID = { + CDNContributor: 'ec156ff8-a8d1-4d15-830c-5b80698ca432' + kvSecretsUser: '4633458b-17de-408a-b874-0445c86b69e6' + networkContributor: '4d97b98b-1d4f-4787-a291-c67834d212e7' + rbacAdmin: 'f58310d9-a9f6-439a-9e8d-f62e7b41a168' + 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/lung_cancer_screening: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 + 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 + } +} + +// 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 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 + 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 + } +} + +// Let the managed identity configure Front door and its resources +resource CDNContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, envConfig, 'CDNContributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.CDNContributor) + principalId: managedIdentiyADOtoAZ.outputs.miPrincipalID + description: '${miADOtoAZname} CDN Contributor access to subscription' + } +} + +// Let the managed identity assign the Key Vault Secrets User role to the container app managed identity +resource rbacAdminAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, envConfig, 'rbacAdmin') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.rbacAdmin) + principalId: managedIdentiyADOtoAZ.outputs.miPrincipalID + condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.kvSecretsUser}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.kvSecretsUser}}))' + conditionVersion: '2.0' + description: '${miADOtoAZname} Role Based Access Control Administrator access to subscription. Only allows assigning the Key Vault Secrets User role.' + } +} + +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..6ebc97e3 --- /dev/null +++ b/infrastructure/terraform/resource_group_init/storage.bicep @@ -0,0 +1,71 @@ +param storageLocation string +param storageName string +param enableSoftDelete bool +param miPrincipalID string +param miName 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' +} + +// Let the managed identity edit the terraform state +resource blobContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(subscription().subscriptionId, miPrincipalID, 'blobContributor') + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleID.blobContributor) + principalId: miPrincipalID + description: '${miName} Network Contributor access to subscription' + } +} + +// Output the storage account ID so it can be used to create the private endpoint +output storageAccountID string = storageAccount.id