diff --git a/deploy/deploy-new.ps1 b/deploy/deploy-new.ps1 new file mode 100644 index 0000000..3d75417 --- /dev/null +++ b/deploy/deploy-new.ps1 @@ -0,0 +1,184 @@ +[CmdletBinding()] +param ( + [Parameter()] + [ValidateScript( + { (Get-AzLocation).Location -contains $_ }, + ErrorMessage = "{0} is not a valid Azure Region." + )] + [string] + $Location = 'eastus', + + <#[Parameter()] + [ValidateSet( + 'AppServiceZip', + 'AppServiceContainer', + 'FunctionZip', + 'FunctionContainer' + )] + [string] + $Architecture = 'AppServiceContainer', + #> + + [Parameter()] + [ValidateScript( + { (Test-Path $_ -PathType Leaf) -and ($_.EndsWith('.bicep')) }, + ErrorMessage = "{0} is not an existing .bicep file." + )] + [string] + $IdentityTemplateFile = './identity/main.bicep', + + [Parameter()] + [ValidateScript( + { (Test-Path $_ -PathType Leaf) -and ($_.EndsWith('.bicepparam')) }, + ErrorMessage = "{0} is not an existing .bicepparam file." + )] + [string] + $IdentityParameterFile = './identity/main.bicepparam', + + [Parameter()] + [ValidateScript( + { (Test-Path $_ -PathType Leaf) -and ($_.EndsWith('.bicep')) }, + ErrorMessage = "{0} is not an existing .bicep file." + )] + [string] + $InfrastructureTemplateFile = './main.bicep', + + [Parameter()] + [ValidateScript( + { (Test-Path $_ -PathType Leaf) -and ($_.EndsWith('.bicepparam')) }, + ErrorMessage = "{0} is not an existing .bicepparam file." + )] + [string] + $InfrastructureParameterFile = './main.bicepparam', + + [Parameter()] + [ValidateNotNullOrWhiteSpace()] + [string] + $SubscriptionIdForInfrastructureDeployment = (Get-AzContext).Subscription.Id, + + [Parameter()] + [ValidateNotNullOrWhiteSpace()] + [string] + $ManagementGroupIdForIdentityDeployment = (Get-AzContext).Tenant.Id, + + [Parameter()] + [bool] + $IncludeIdentities = $true, + + [Parameter()] + [bool] + $IncludeInfrastructure = $true +) + +begin { + # Set preference variables + $ErrorActionPreference = "Stop" + $ProgressPreference = 'SilentlyContinue' + + # Check for Debug Flag + $DEBUG_MODE = [bool]$PSCmdlet.MyInvocation.BoundParameters["Debug"].IsPresent + + # Hide Azure PowerShell SDK Warnings + $Env:SuppressAzurePowerShellBreakingChangeWarnings = $true + + # Hide Azure PowerShell SDK & Azure CLI Survey Prompts + $Env:AzSurveyMessage = $false + $Env:AZURE_CORE_SURVEY_MESSAGE = $false +} # begin + + +process { + + # initial identity deployment + if ($IncludeIdentities) { + Write-Debug "Deploying identity resources [initial]" + $identityDeploySplat = @{ + DeploymentName = -join ('ipamIdentityDeploy-{0}' -f (Get-Date -Format 'yyyyMMddTHHMMss'))[0..63] + Location = $Location + TemplateFile = $IdentityTemplateFile + TemplateParameterFile = $IdentityParameterFile + WhatIf = $false + Verbose = $DEBUG_MODE + ManagementGroupId = $ManagementGroupIdForIdentityDeployment + } + + $identityDeplyment = New-AzManagementGroupDeployment @identityDeploySplat + Write-Debug "Deployed identity resources [initial]" + } + + + # infrastructure deployment + if ($IncludeInfrastructure) { + Write-Debug "Deploying infrastructure resources" + $infrastructureDeploySplat = @{ + DeploymentName = -join ('ipamInfrastructureDeploy-{0}' -f (Get-Date -Format 'yyyyMMddTHHMMss'))[0..63] + Location = $Location + TemplateFile = $InfrastructureTemplateFile + TemplateParameterFile = $InfrastructureParameterFile + WhatIf = $false + Verbose = $DEBUG_MODE + } + + # include appIds from identity deployment if it was included + if ($IncludeIdentities) { + $infrastructureDeploySplat.Add('uiAppId', $identityDeplyment.Outputs.uiAppId.Value) + $infrastructureDeploySplat.Add('engineAppId', $identityDeplyment.Outputs.engineAppId.Value) + } + + Select-AzSubscription -SubscriptionId $SubscriptionIdForInfrastructureDeployment + $infrastructureDeployment = New-AzSubscriptionDeployment @infrastructureDeploySplat + Write-Debug "Deployed infrastructure resources" + } + + + # full identity deployment + if ($IncludeIdentities) { + Write-Debug "Deploying identity resources [full]" + $identityDeploySplat = @{ + DeploymentName = -join ('ipamIdentityDeploy-{0}' -f (Get-Date -Format 'yyyyMMddTHHMMss'))[0..63] + Location = $Location + TemplateFile = $IdentityTemplateFile + TemplateParameterFile = $IdentityParameterFile + WhatIf = $false + Verbose = $DEBUG_MODE + ManagementGroupId = $ManagementGroupIdForIdentityDeployment + # pass appIds from initial deployment + uiAppId = $identityDeplyment.Outputs.uiAppId.Value + engineAppId = $identityDeplyment.Outputs.engineAppId.Value + } + + # pass uiAppRedirectUris from infrastructure deployment, if it was included + if ($IncludeInfrastructure) { + $identityDeploySplat.Add('uiAppRedirectUris', @( $infrastructureDeployment.Outputs.appServiceHostName.Value )) + } + + $identityDeplyment = New-AzManagementGroupDeployment @identityDeploySplat + Write-Debug "Deployed identity resources [full]" + } + + + # az acr build + if ($IncludeInfrastructure -and $infrastructureDeployment.Outputs.acrName.Value.Length -gt 1) { + Write-Debug "Building and pushing container image to Azure Container Registry" + } + + + # archive and publish .zip + if ($IncludeInfrastructure -and [bool]$infrastructureDeployment.Outputs.zipDeployNeeded.Value) { + Write-Debug "Creating ZIP Deploy archive" + Write-Debug "Uploading ZIP Deploy archive" + } + + + # catch any errors + trap { + Write-Debug "Inside trap block, an error occurred" + throw + # break + } + +} # process + +end { + +} # end \ No newline at end of file diff --git a/deploy/identity/bicepconfig.json b/deploy/identity/bicepconfig.json new file mode 100644 index 0000000..95ae339 --- /dev/null +++ b/deploy/identity/bicepconfig.json @@ -0,0 +1,5 @@ +{ + "experimentalFeaturesEnabled": { + "extensibility": true + } +} \ No newline at end of file diff --git a/deploy/identity/main.bicep b/deploy/identity/main.bicep new file mode 100644 index 0000000..173a46b --- /dev/null +++ b/deploy/identity/main.bicep @@ -0,0 +1,189 @@ +targetScope = 'managementGroup' + +@description('Display name for the IPAM UI App Registration.') +param uiAppName string = 'ipam-ui-app' + +@description('AppId of the IPAM UI App Registration. Fill in this after first deployment!') +param uiAppId string = '' + +@description('Display name for the IPAM Engine App Registration.') +param engineAppName string = 'ipam-engine-app' + +@description('AppId of the IPAM Engine App Registration. Fill in this after first deployment!') +param engineAppId string = '' + +@description('Flag to disable the IPAM UI.') +param disableUi bool = false + +@description('Redirect URIs for the IPAM UI App Registration. Update this when the infrastructure is deployed!') +param uiAppRedirectUris string[] = ['https://replace-this-value.azurewebsites.net'] + +@description('Guid for the IPAM Engine API Permission.') +param engineAppApiPermissionGuid string = guid('myOrgNameHereForUniqueness') + +@description('Management group IDs where the IPAM Engine will get Reader permissions. e.g. [\'alz-platform-connectivity\',\'alz-landingzones-corp\']') +param engineReaderRoleManagementGroupIds string[] = [tenant().tenantId] + +@description('Azure cloud environment. Verify in pwsh with (Get-AzContext).Environment.Name') +@allowed([ + 'AZURE_PUBLIC' + 'AZURE_US_GOV' + 'AZURE_US_GOV_SECRET' + 'AZURE_GERMANY' + 'AZURE_CHINA' +]) +param azureCloud string = 'AZURE_PUBLIC' + +var engineResourceMap = { + AZURE_PUBLIC: { + resourceAppId: '797f4846-ba00-4fd7-ba43-dac1f8f63013' // Azure Service Management + resourceAccessIds: [ + { id: '41094075-9dad-400e-a0bd-54e686782033', type: 'Scope' } // user_impersonation + ] + } + + AZURE_US_GOV: { + resourceAppId: '40a69793-8fe6-4db1-9591-dbc5c57b17d8' // Azure Service Management + resourceAccessIds: [ + { id: '8eb49ffc-05ac-454c-9027-8648349217dd', type: 'Scope' } // user_impersonation + { id: 'e59ee429-1fb1-4054-b99f-f542e8dc9b95', type: 'Scope' } // user_impersonation + ] + } + + AZURE_US_GOV_SECRET: { + resourceAppId: '797f4846-ba00-4fd7-ba43-dac1f8f63013' // Azure Service Management + resourceAccessIds: [ + { id: '41094075-9dad-400e-a0bd-54e686782033', type: 'Scope' } // user_impersonation + ] + } + + AZURE_GERMANY: { + ResourceAppId: '797f4846-ba00-4fd7-ba43-dac1f8f63013' // Azure Service Management + resourceAccessIds: [ + { id: '41094075-9dad-400e-a0bd-54e686782033', type: 'Scope' } // user_impersonation + ] + } + + AZURE_CHINA: { + ResourceAppId: '797f4846-ba00-4fd7-ba43-dac1f8f63013' // Azure Service Management + resourceAccessIds: [ + { id: '41094075-9dad-400e-a0bd-54e686782033', type: 'Scope' } // user_impersonation + ] + } +} + +// Initialize the Graph provider / extension: https://github.com/Azure/bicep/issues/14374 +provider microsoftGraph + +// Get the Resource Id of the Graph resource in the tenant +resource graphSpn 'Microsoft.Graph/servicePrincipals@v1.0' existing = { + appId: '00000003-0000-0000-c000-000000000000' +} + +// Get the Resource Id of the Microsoft Azure Management / Azure Service Management resource in the tenant +resource microsoftAzureManagementSpn 'Microsoft.Graph/servicePrincipals@v1.0' existing = { + appId: '797f4846-ba00-4fd7-ba43-dac1f8f63013' +} + +resource engineApp 'Microsoft.Graph/applications@v1.0' = if (!disableUi) { + displayName: engineAppName + uniqueName: engineAppName + requiredResourceAccess: [ + { + resourceAppId: engineResourceMap[azureCloud].resourceAppId + resourceAccess: engineResourceMap[azureCloud].resourceAccessIds + } + ] + identifierUris: empty(engineAppId) ? [] : ['api://${engineAppId}'] + api: { + requestedAccessTokenVersion: 2 + knownClientApplications: disableUi || empty(uiAppId) ? [] : [uiAppId] // avoid sircular dependency + oauth2PermissionScopes: [ + { + adminConsentDescription: 'Allows the IPAM UI to access IPAM Engine API as the signed-in user.' + adminConsentDisplayName: 'Access IPAM Engine API' + id: engineAppApiPermissionGuid + isEnabled: true + type: 'User' + userConsentDescription: 'Allow the IPAM UI to access IPAM Engine API on your behalf.' + userConsentDisplayName: 'Access IPAM Engine API' + value: 'access_as_user' + } + ] + preAuthorizedApplications: [ + { delegatedPermissionIds: [engineAppApiPermissionGuid], appId: '1950a258-227b-4e31-a9cf-717495945fc2' } // Azure PowerShell + { delegatedPermissionIds: [engineAppApiPermissionGuid], appId: '04b07795-8ddb-461a-bbee-02f9e1bf7b46' } // Azure CLI + ] + } +} + +resource engineSpn 'Microsoft.Graph/servicePrincipals@v1.0' = { + appId: engineApp.appId +} + +module roleAssignments 'br/public:avm/ptn/authorization/role-assignment:0.1.0' = [ + for mgId in engineReaderRoleManagementGroupIds: { + name: guid(engineAppName, 'Reader', mgId) + params: { + principalId: engineSpn.id + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: 'Reader' + managementGroupId: mgId + } + } +] + +resource uiApp 'Microsoft.Graph/applications@v1.0' = if (!disableUi) { + displayName: uiAppName + uniqueName: uiAppName + spa: { redirectUris: uiAppRedirectUris } + // web: { redirectUris: uiAppRedirectUris } + requiredResourceAccess: [ + { + resourceAppId: graphSpn.appId + resourceAccess: [ + { id: '37f7f235-527c-4136-accd-4a02d197296e', type: 'Scope' } // openid + { id: '14dad69e-099b-42c9-810b-d002981feec1', type: 'Scope' } // profile + { id: '7427e0e9-2fba-42fe-b0c0-848c9e6a8182', type: 'Scope' } // offline_access + { id: 'e1fe6dd8-ba31-4d61-89e7-88639da4683d', type: 'Scope' } // User.Read + { id: '06da0dbc-49e2-44d2-8312-53f166ab848a', type: 'Scope' } // Directory.Read.All + ] + } + { + resourceAppId: engineApp.appId // opportunity for circular dependency + resourceAccess: [ + { id: engineAppApiPermissionGuid, type: 'Scope' } + ] + } + ] +} + +resource uiSpn 'Microsoft.Graph/servicePrincipals@v1.0' = if (!disableUi) { + appId: uiApp.appId +} + +resource uiSpnGrantPermissionToMsGraphApi 'Microsoft.Graph/oauth2PermissionGrants@v1.0' = if (!disableUi) { + clientId: uiSpn.id + consentType: 'AllPrincipals' + resourceId: graphSpn.id + scope: 'openid profile offline_access User.Read Directory.Read.All' +} + +resource uiSpnGrantPermissionToEngineApi 'Microsoft.Graph/oauth2PermissionGrants@v1.0' = if (!disableUi) { + clientId: uiSpn.id + consentType: 'AllPrincipals' + resourceId: engineSpn.id + scope: 'access_as_user' +} + +resource engineSpnGrantPermissionToAzureServiceManagementApi 'Microsoft.Graph/oauth2PermissionGrants@v1.0' = { + clientId: engineSpn.id + consentType: 'AllPrincipals' + resourceId: microsoftAzureManagementSpn.id + scope: 'user_impersonation' +} + +output uiAppId string = uiApp.appId +output engineAppId string = engineApp.appId +output engineAppIdentifierUris array = engineApp.identifierUris +output engineAppKnownClientApplications array = engineApp.api.knownClientApplications diff --git a/deploy/identity/main.example.bicepparam b/deploy/identity/main.example.bicepparam new file mode 100644 index 0000000..94e83b1 --- /dev/null +++ b/deploy/identity/main.example.bicepparam @@ -0,0 +1,11 @@ +using './main.bicep' + +param uiAppName = 'ipam-ui-app' +param uiAppId = '' +param engineAppName = 'ipam-engine-app' +param engineAppId = '' +param disableUi = false +param uiAppRedirectUris = ['https://replace-this-value.azurewebsites.net'] +param engineAppApiPermissionGuid = guid('myOrgNameHereForUniqueness') +param engineReaderRoleManagementGroupIds = [''] +param azureCloud = 'AZURE_PUBLIC' diff --git a/deploy/main.bicep b/deploy/main.bicep index fa29bb9..a9ad2d2 100644 --- a/deploy/main.bicep +++ b/deploy/main.bicep @@ -187,3 +187,4 @@ output appServiceName string = deployAsFunc ? resourceNames.functionName : resou output appServiceHostName string = deployAsFunc ? functionApp.outputs.functionAppHostName : appService.outputs.appServiceHostName output acrName string = privateAcr ? containerRegistry.outputs.acrName : '' output acrUri string = privateAcr ? containerRegistry.outputs.acrUri : '' +output zipDeployNeeded bool = deployAsContainer ? false : true diff --git a/deploy/main.example.bicepparam b/deploy/main.example.bicepparam new file mode 100644 index 0000000..4be3ea5 --- /dev/null +++ b/deploy/main.example.bicepparam @@ -0,0 +1,30 @@ +using './main.bicep' + +param guid = sys.guid('') +param location = 'westeurope' +param namePrefix = 'ipam' +param azureCloud = 'AZURE_PUBLIC' +param privateAcr = false +param deployAsFunc = false +param deployAsContainer = true +param uiAppId = '' +param engineAppId = '' +param engineAppSecret = sys.readEnvironmentVariable('ENGINE_APP_SECRET') // change to getSecret() after the initial deployment +// param engineAppSecret = az.getSecret('', '', '', '', '') +param tags = {} +param resourceNames = { + functionName: '${namePrefix}-${uniqueString(guid)}' + appServiceName: '${namePrefix}-${uniqueString(guid)}' + functionPlanName: '${namePrefix}-asp-${uniqueString(guid)}' + appServicePlanName: '${namePrefix}-asp-${uniqueString(guid)}' + cosmosAccountName: '${namePrefix}-dbacct-${uniqueString(guid)}' + cosmosContainerName: '${namePrefix}-ctr' + cosmosDatabaseName: '${namePrefix}-db' + keyVaultName: '${namePrefix}-kv-${uniqueString(guid)}' + workspaceName: '${namePrefix}-law-${uniqueString(guid)}' + managedIdentityName: '${namePrefix}-mi-${uniqueString(guid)}' + resourceGroupName: '${namePrefix}-rg-${uniqueString(guid)}' + storageAccountName: '${namePrefix}stg${uniqueString(guid)}' + containerRegistryName: '${namePrefix}acr${uniqueString(guid)}' +} + diff --git a/docs/deployment/README.md b/docs/deployment/README.md index 579464a..e32805e 100644 --- a/docs/deployment/README.md +++ b/docs/deployment/README.md @@ -18,7 +18,7 @@ To successfully deploy the solution, the following prerequisites must be met: - [Azure PowerShell](https://learn.microsoft.com/powershell/azure/install-az-ps) version 8.0.0 or later installed (11.4.0 or later recommended) - [Microsoft Graph PowerShell SDK](https://learn.microsoft.com/powershell/microsoftgraph/installation) version 2.0.0 or later installed - Required for *Full* or *Identities Only* deployments to grant [Admin Consent](https://learn.microsoft.com/azure/active-directory/manage-apps/grant-admin-consent) to the App Registrations -- [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) version 0.21.1 or later installed +- [Bicep CLI](https://learn.microsoft.com/azure/azure-resource-manager/bicep/install) version 0.28.1 or later installed - [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) version 2.35.0 or later installed (optional) - Required only if you are building your own container image and pushing it to a private Azure Container Registry (Private ACR) - [Docker (Linux)](https://docs.docker.com/engine/install/) / [Docker Desktop (Windows)](https://docs.docker.com/desktop/install/windows-install/) installed (optional) diff --git a/docs/deployment/bicep-bootstrapping.md b/docs/deployment/bicep-bootstrapping.md new file mode 100644 index 0000000..feb848a --- /dev/null +++ b/docs/deployment/bicep-bootstrapping.md @@ -0,0 +1,27 @@ +# Bicep bootstrapping + +The Entra Id app registrations and Azure infrastructure is bundled into seperate `main.bicep` files. +- `deploy/identity/main.bicep` +- `deploy/main.bicep` (move infra bicep to its own subfolder?) + + +## Configuration +All configuration should be done in `.bicepparam`-files. + +## Challenges +The Entra Id app registration deployment has circular dependencies (both depend on values from the other for a complete setup): +- Engine app should include uiAppId as knownClientApplications. +- UI app should include engineAppId in requiredResourceAccess. + +The redirectUri for the UI app might not be known until after the infrastructure is deployed. + +## Solution +The Entra Id app registration deployment is executed twice. +1. The 1st identity deployment happens without any value for the params `uiAppId` and `engineAppId`, and a placeholder value for `uiAppRedirectUris`. +2. The 1st infrastructure deployment happens with the params `uiAppId` and `engineAppId` filled with values from the 1st identity deployment. +3. The 2nd identity deployment include the params `uiAppId`, `engineAppId` and `uiAppRedirectUris`. +4. Build to any private ACR. +5. Archive and publish .zip. + + +The `deploy` directory includes the following example script `deploy-all.ps1`.