From b1b269534a7b919b2bc8c3851f4187ed95ea2b4d Mon Sep 17 00:00:00 2001 From: Lukas Domin Date: Wed, 20 May 2026 09:45:56 +0200 Subject: [PATCH 1/3] Update Azure Landing Zone CLI package --- .../Install-AzureLandingZone.ps1 | 1610 +++++++++++++++++ AzureLandingZoneforNonprofits/README.md | 44 +- .../cli-package-manifest.json | 38 + .../mgmtGroupStructure/mgmtGroups.json | 32 - .../Custom-RBACDefinitions.json | 122 -- .../subscriptionOrganization.json | 30 - .../subscriptionTemplates/defenderCloud.json | 85 - .../subscriptionTemplates/enableDDoS.json | 65 - .../subscriptionTemplates/hubNetwork.json | 102 -- .../core/subscriptionTemplates/keyVault.json | 69 - .../logAnalyticsWorkspace.json | 69 - .../peeringHubSpoke.json | 63 - .../peeringSpokeHub.json | 63 - .../recoveryServicesVault.json | 67 - .../subscriptionTemplates/spokeNetwork.json | 91 - .../subscriptionTemplates/vpnGateway.json | 113 -- .../expanded-platform.install-config.json | 19 + .../commands/foundation.install-config.json | 17 + .../examples/parameters/README.md | 5 + .../expanded-platform.parameters.json | 60 + ...undation.subscription-only.parameters.json | 43 + .../infra/entrypoints/direct/README.md | 12 + .../direct/expanded-platform.bicep | 315 ++++ .../direct/foundation-subscription.bicep | 171 ++ .../infra/modules/governance/README.md | 15 + .../governance/foundation-budget.bicep | 64 + ...management-group-governance-baseline.bicep | 119 ++ .../resource-group-tag-inheritance.bicep | 53 + .../subscription-governance-baseline.bicep | 118 ++ .../infra/modules/identity/README.md | 14 + .../resource-group-role-assignment.bicep | 33 + .../subscription-access-baseline.bicep | 68 + .../identity/workspace-role-assignment.bicep | 41 + .../infra/modules/monitoring/README.md | 19 + .../monitoring/activity-log-alerts.bicep | 108 ++ .../monitoring/keyvault-diagnostics.bicep | 41 + .../subscription-monitoring-baseline.bicep | 170 ++ .../virtual-network-diagnostics.bicep | 41 + .../infra/modules/networking/README.md | 20 + .../expanded-hub-network-resources.bicep | 142 ++ .../networking/expanded-network-profile.bicep | 73 + .../foundation-network-profile.bicep | 68 + .../foundation-network-resources.bicep | 135 ++ .../networking/validation/bicepconfig.json | 5 + .../foundation-input-validation.bicep | 17 + .../infra/modules/security/README.md | 20 + .../subscription-security-baseline.bicep | 65 + .../infra/modules/shared/README.md | 15 + .../shared/foundation-platform-baseline.bicep | 87 + .../shared/resource-group-baseline.bicep | 99 + .../shared/subscription-platform-slice.bicep | 88 + AzureLandingZoneforNonprofits/scenarios.json | 81 + .../tsismallarm.json | 707 -------- .../tsismallportal.json | 657 ------- 54 files changed, 4144 insertions(+), 2344 deletions(-) create mode 100644 AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 create mode 100644 AzureLandingZoneforNonprofits/cli-package-manifest.json delete mode 100644 AzureLandingZoneforNonprofits/core/managementGroupTemplates/mgmtGroupStructure/mgmtGroups.json delete mode 100644 AzureLandingZoneforNonprofits/core/managementGroupTemplates/roleDefinitions/Custom-RBACDefinitions.json delete mode 100644 AzureLandingZoneforNonprofits/core/managementGroupTemplates/subscriptionOrganization/subscriptionOrganization.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/defenderCloud.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/enableDDoS.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/hubNetwork.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/keyVault.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/logAnalyticsWorkspace.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringHubSpoke.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringSpokeHub.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/recoveryServicesVault.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/spokeNetwork.json delete mode 100644 AzureLandingZoneforNonprofits/core/subscriptionTemplates/vpnGateway.json create mode 100644 AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json create mode 100644 AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json create mode 100644 AzureLandingZoneforNonprofits/examples/parameters/README.md create mode 100644 AzureLandingZoneforNonprofits/examples/parameters/expanded-platform/expanded-platform.parameters.json create mode 100644 AzureLandingZoneforNonprofits/examples/parameters/foundation/foundation.subscription-only.parameters.json create mode 100644 AzureLandingZoneforNonprofits/infra/entrypoints/direct/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/entrypoints/direct/expanded-platform.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/entrypoints/direct/foundation-subscription.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/governance/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/modules/governance/foundation-budget.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/governance/management-group-governance-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/governance/resource-group-tag-inheritance.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/governance/subscription-governance-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/identity/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/modules/identity/resource-group-role-assignment.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/identity/subscription-access-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/identity/workspace-role-assignment.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/monitoring/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/modules/monitoring/activity-log-alerts.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/monitoring/keyvault-diagnostics.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/monitoring/subscription-monitoring-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/monitoring/virtual-network-diagnostics.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/expanded-hub-network-resources.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/expanded-network-profile.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-profile.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-resources.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/validation/bicepconfig.json create mode 100644 AzureLandingZoneforNonprofits/infra/modules/networking/validation/foundation-input-validation.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/security/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/modules/security/subscription-security-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/shared/README.md create mode 100644 AzureLandingZoneforNonprofits/infra/modules/shared/foundation-platform-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/shared/resource-group-baseline.bicep create mode 100644 AzureLandingZoneforNonprofits/infra/modules/shared/subscription-platform-slice.bicep create mode 100644 AzureLandingZoneforNonprofits/scenarios.json delete mode 100644 AzureLandingZoneforNonprofits/tsismallarm.json delete mode 100644 AzureLandingZoneforNonprofits/tsismallportal.json diff --git a/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 b/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 new file mode 100644 index 00000000..32d1f448 --- /dev/null +++ b/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 @@ -0,0 +1,1610 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$ConfigFile, + + [Parameter(Mandatory = $true)] + [ValidateSet('validate', 'what-if', 'create')] + [string]$Action, + + [string]$OutputFolder = '', + + [ValidateSet('Provider', 'ProviderNoRbac', 'Template')] + [string]$ValidationLevel = $null, + + [switch]$NonInteractive, + + [switch]$AutoApprove +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-AlzInstallContextFromPath { + param( + [Parameter(Mandatory = $true)] + [string]$StartPath + ) + + $current = Get-Item -LiteralPath $StartPath + if ($current -isnot [System.IO.DirectoryInfo]) { + $current = $current.Directory + } + + while ($null -ne $current) { + $packageScenarioManifestPath = Join-Path $current.FullName 'scenarios.json' + if (Test-Path $packageScenarioManifestPath -PathType Leaf) { + if ($current.Name -ieq 'cli' -and $null -ne $current.Parent -and (Test-Path (Join-Path $current.Parent.FullName 'infra') -PathType Container)) { + return [ordered]@{ + root = $current.Parent.FullName + scenarioManifestPath = $packageScenarioManifestPath + } + } + + return [ordered]@{ + root = $current.FullName + scenarioManifestPath = $packageScenarioManifestPath + } + } + + $sourceScenarioManifestPath = Join-Path $current.FullName 'cli\scenarios.json' + if (Test-Path $sourceScenarioManifestPath -PathType Leaf) { + return [ordered]@{ + root = $current.FullName + scenarioManifestPath = $sourceScenarioManifestPath + } + } + + $current = $current.Parent + } + + throw "Could not locate Azure Landing Zone CLI scenario catalog from '$StartPath'." +} + +$script:AlzContext = Get-AlzInstallContextFromPath -StartPath $PSScriptRoot +$script:AlzRoot = [string]$script:AlzContext.root +$script:ScenarioManifestPath = [string]$script:AlzContext.scenarioManifestPath +$script:LogFile = '' + +function Write-InstallerLog { + param( + [Parameter(Mandatory = $true)] + [string]$Message, + + [ValidateSet('INFO', 'WARNING', 'ERROR', 'SUCCESS')] + [string]$Level = 'INFO' + ) + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $line = "[$timestamp] [$Level] $Message" + + if (-not [string]::IsNullOrWhiteSpace($script:LogFile)) { + Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 + } + + switch ($Level) { + 'ERROR' { Write-Host $Message -ForegroundColor Red } + 'WARNING' { Write-Host $Message -ForegroundColor Yellow } + 'SUCCESS' { Write-Host $Message -ForegroundColor Green } + default { Write-Host $Message } + } +} + +function Read-JsonDocument { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + return Get-Content -Path $Path -Raw -Encoding UTF8 | ConvertFrom-Json -AsHashtable -Depth 100 +} + +function Write-JsonDocument { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [object]$Value + ) + + $directory = Split-Path -Parent $Path + if (-not (Test-Path $directory)) { + New-Item -Path $directory -ItemType Directory -Force | Out-Null + } + + $json = $Value | ConvertTo-Json -Depth 100 + Set-Content -Path $Path -Value $json -Encoding UTF8 +} + +function Resolve-PathAgainstRoot { + param( + [Parameter(Mandatory = $true)] + [string]$PathValue, + + [Parameter(Mandatory = $true)] + [string]$ConfigDirectory + ) + + if ([System.IO.Path]::IsPathRooted($PathValue)) { + return [System.IO.Path]::GetFullPath($PathValue) + } + + $configRelative = [System.IO.Path]::GetFullPath((Join-Path $ConfigDirectory $PathValue)) + if (Test-Path $configRelative) { + return $configRelative + } + + return [System.IO.Path]::GetFullPath((Join-Path $script:AlzRoot $PathValue)) +} + +function Format-CommandLine { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments + ) + + $quoted = foreach ($argument in $Arguments) { + if ($argument -match '\s') { + '"{0}"' -f $argument + } + else { + $argument + } + } + + return 'az ' + ($quoted -join ' ') +} + +function Get-OutputValue { + param( + [hashtable]$Outputs, + [string]$Name, + [object]$DefaultValue = $null + ) + + if ($null -eq $Outputs -or -not $Outputs.ContainsKey($Name)) { + return $DefaultValue + } + + $output = $Outputs[$Name] + if ($output -is [hashtable] -and $output.ContainsKey('value')) { + return $output['value'] + } + + return $output +} + +function Test-RequiredTooling { + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw 'PowerShell 7 or later is required.' + } + + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw 'Azure CLI is required but was not found on PATH.' + } + + $azVersionDocument = & az version -o json --only-show-errors 2>$null | Out-String + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($azVersionDocument)) { + throw 'Unable to determine the Azure CLI version.' + } + + try { + $azVersion = ($azVersionDocument | ConvertFrom-Json -AsHashtable -Depth 20)['azure-cli'] + } + catch { + throw 'Unable to parse the Azure CLI version.' + } + + if ([string]::IsNullOrWhiteSpace([string]$azVersion)) { + throw 'Unable to parse the Azure CLI version.' + } + + if ([version]$azVersion -lt [version]'2.76.0') { + throw "Azure CLI 2.76.0 or later is required. Current version: $azVersion" + } + + & az bicep version 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + throw 'Azure CLI Bicep support is required. Run az bicep install or az bicep upgrade.' + } + + & az account show -o none 2>$null + if ($LASTEXITCODE -ne 0) { + throw 'Azure authentication is required. Run az login before using this installer.' + } +} + +function Resolve-ScenarioDefinition { + param( + [Parameter(Mandatory = $true)] + [string]$RequestedScenario, + + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioManifest + ) + + foreach ($scenario in $ScenarioManifest.scenarios) { + foreach ($alias in $scenario.aliases) { + if ($alias -ieq $RequestedScenario) { + return $scenario + } + } + } + + throw "Unsupported scenario '$RequestedScenario'." +} + +function Test-ScenarioModeActivation { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ModeDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues + ) + + if (-not $ModeDefinition.ContainsKey('activation') -or $null -eq $ModeDefinition.activation) { + return $false + } + + $activation = [hashtable]$ModeDefinition.activation + $requiredParameters = @() + if ($activation.ContainsKey('requiredParameters') -and $null -ne $activation.requiredParameters) { + $requiredParameters = @($activation.requiredParameters) + } + + foreach ($parameterName in $requiredParameters) { + if (-not $ParameterValues.ContainsKey([string]$parameterName)) { + return $false + } + + $parameterValue = $ParameterValues[[string]$parameterName] + if ($null -eq $parameterValue) { + return $false + } + + if ($parameterValue -is [string] -and [string]::IsNullOrWhiteSpace([string]$parameterValue)) { + return $false + } + } + + if ($activation.ContainsKey('requiredParameterValues') -and $null -ne $activation.requiredParameterValues) { + foreach ($parameterEntry in ([hashtable]$activation.requiredParameterValues).GetEnumerator()) { + $parameterName = [string]$parameterEntry.Key + $expectedValue = $parameterEntry.Value + $hasValue = $ParameterValues.ContainsKey($parameterName) + $actualValue = if ($hasValue) { $ParameterValues[$parameterName] } else { $null } + + if (-not $hasValue -or $actualValue -ne $expectedValue) { + return $false + } + } + } + + return $true +} + +function Resolve-ScenarioMode { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues + ) + + $internalModes = @() + if ($ScenarioDefinition.ContainsKey('internalModes') -and $null -ne $ScenarioDefinition.internalModes) { + $internalModes = @($ScenarioDefinition.internalModes) + } + + foreach ($internalMode in $internalModes) { + if (-not (Test-ScenarioModeActivation -ModeDefinition ([hashtable]$internalMode) -ParameterValues $ParameterValues)) { + continue + } + + $resolvedScenarioDefinition = [ordered]@{} + foreach ($entry in $ScenarioDefinition.GetEnumerator()) { + if ($entry.Key -eq 'internalModes') { + continue + } + + $resolvedScenarioDefinition[$entry.Key] = $entry.Value + } + + foreach ($entry in ([hashtable]$internalMode).GetEnumerator()) { + if ($entry.Key -eq 'activation') { + continue + } + + $resolvedScenarioDefinition[$entry.Key] = $entry.Value + } + + return $resolvedScenarioDefinition + } + + return $ScenarioDefinition +} + +function Resolve-SubscriptionReference { + param( + [Parameter(Mandatory = $true)] + [string]$SubscriptionReference + ) + + $resultText = & az account show --subscription $SubscriptionReference -o json --only-show-errors 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "We could not access the selected subscription '$SubscriptionReference'." + } + + return $resultText | ConvertFrom-Json -AsHashtable -Depth 20 +} + +function Resolve-ManagementGroupReference { + param( + [Parameter(Mandatory = $true)] + [string]$ManagementGroupReference + ) + + $resultText = & az account management-group show --name $ManagementGroupReference -o json --only-show-errors 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "We could not access the selected management group '$ManagementGroupReference'." + } + + return $resultText | ConvertFrom-Json -AsHashtable -Depth 20 +} + +function Resolve-ResourceReference { + param( + [Parameter(Mandatory = $true)] + [string]$ResourceId + ) + + $resultText = & az resource show --ids $ResourceId -o json --only-show-errors 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "We could not access the selected resource '$ResourceId'." + } + + return $resultText | ConvertFrom-Json -AsHashtable -Depth 20 +} + +function Get-EffectiveParameterValues { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ParameterDocument + ) + + $values = @{} + foreach ($entry in $ParameterDocument.parameters.GetEnumerator()) { + if ($entry.Value -is [hashtable] -and $entry.Value.ContainsKey('value')) { + $values[$entry.Key] = $entry.Value['value'] + } + } + + return $values +} + +function Get-OptionalStringParameterValue { + param( + [hashtable]$ParameterValues, + [string]$Name + ) + + if ($null -eq $ParameterValues -or -not $ParameterValues.ContainsKey($Name) -or $null -eq $ParameterValues[$Name]) { + return '' + } + + return [string]$ParameterValues[$Name] +} + +function Get-BooleanParameterValue { + param( + [hashtable]$ParameterValues, + [string]$Name + ) + + if ($null -eq $ParameterValues -or -not $ParameterValues.ContainsKey($Name)) { + return $false + } + + $value = $ParameterValues[$Name] + return $value -is [bool] -and $value +} + +function Get-ArrayParameterValue { + param( + [hashtable]$ParameterValues, + [string]$Name + ) + + if ($null -eq $ParameterValues -or -not $ParameterValues.ContainsKey($Name) -or $null -eq $ParameterValues[$Name]) { + return @() + } + + $value = $ParameterValues[$Name] + if ($value -is [string]) { + if ([string]::IsNullOrWhiteSpace([string]$value)) { + return @() + } + + return @([string]$value) + } + + return @($value) +} + +function Test-WarningParameterActive { + param( + [hashtable]$ParameterValues, + [string]$Name + ) + + if ($null -eq $ParameterValues -or -not $ParameterValues.ContainsKey($Name) -or $null -eq $ParameterValues[$Name]) { + return $false + } + + $value = $ParameterValues[$Name] + if ($value -is [bool]) { + return [bool]$value + } + + if ($value -is [byte] -or $value -is [sbyte] -or $value -is [short] -or $value -is [ushort] -or $value -is [int] -or $value -is [uint] -or $value -is [long] -or $value -is [ulong] -or $value -is [float] -or $value -is [double] -or $value -is [decimal]) { + return [decimal]$value -gt 0 + } + + if ($value -is [string]) { + $trimmedValue = ([string]$value).Trim() + if ([string]::IsNullOrWhiteSpace($trimmedValue)) { + return $false + } + + $parsedBoolean = $false + if ([bool]::TryParse($trimmedValue, [ref]$parsedBoolean)) { + return $parsedBoolean + } + + $parsedNumber = [decimal]0 + if ([decimal]::TryParse($trimmedValue, [System.Globalization.NumberStyles]::Number, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsedNumber)) { + return $parsedNumber -gt 0 + } + + return $true + } + + if ($value -is [System.Array]) { + return @($value).Count -gt 0 + } + + return $true +} + +function Test-ServiceOwnerParameterValue { + param( + [hashtable]$ParameterValues + ) + + $serviceOwner = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name 'serviceOwner' + return $serviceOwner -match '^[^\s@]+@[^\s@]+\.[^\s@]+$' +} + +function Assert-ServiceOwnerParameterValue { + param( + [hashtable]$ParameterValues + ) + + if (-not (Test-ServiceOwnerParameterValue -ParameterValues $ParameterValues)) { + throw "ServiceOwner must be a valid email address or shared mailbox alias, for example platform@example.org." + } +} + +function Test-ActionPatternMatch { + param( + [Parameter(Mandatory = $true)] + [string]$ActionName, + + [Parameter(Mandatory = $true)] + [string]$Pattern + ) + + if ([string]::IsNullOrWhiteSpace($Pattern)) { + return $false + } + + $escapedPattern = [regex]::Escape($Pattern).Replace('\*', '.*') + return $ActionName -imatch ("^{0}$" -f $escapedPattern) +} + +function Get-ScopePermissions { + param( + [Parameter(Mandatory = $true)] + [string]$Scope + ) + + $normalizedScope = $Scope.Trim() + if ($normalizedScope.StartsWith('/')) { + $normalizedScope = $normalizedScope.Substring(1) + } + + $requestUrl = "https://management.azure.com/{0}/providers/Microsoft.Authorization/permissions?api-version=2015-07-01" -f $normalizedScope + $resultText = & az rest --method get --url $requestUrl -o json --only-show-errors 2>&1 | Out-String + if ($LASTEXITCODE -ne 0) { + throw "We could not determine effective permissions at scope '$Scope'." + } + + $parsedResult = $resultText | ConvertFrom-Json -AsHashtable -Depth 50 + if ($parsedResult -is [hashtable] -and $parsedResult.ContainsKey('value') -and $null -ne $parsedResult.value) { + return @($parsedResult.value) + } + + return @($parsedResult) +} + +function Test-ScopePermissionAvailable { + param( + [Parameter(Mandatory = $true)] + [object[]]$PermissionEntries, + + [Parameter(Mandatory = $true)] + [string]$ActionName + ) + + foreach ($permissionEntry in $PermissionEntries) { + if ($permissionEntry -isnot [hashtable]) { + continue + } + + $allowedPatterns = if ($permissionEntry.ContainsKey('actions') -and $null -ne $permissionEntry.actions) { + @($permissionEntry.actions) + } + else { + @() + } + + $deniedPatterns = if ($permissionEntry.ContainsKey('notActions') -and $null -ne $permissionEntry.notActions) { + @($permissionEntry.notActions) + } + else { + @() + } + + $isAllowed = $false + foreach ($pattern in $allowedPatterns) { + if (Test-ActionPatternMatch -ActionName $ActionName -Pattern ([string]$pattern)) { + $isAllowed = $true + break + } + } + + if (-not $isAllowed) { + continue + } + + foreach ($pattern in $deniedPatterns) { + if (Test-ActionPatternMatch -ActionName $ActionName -Pattern ([string]$pattern)) { + $isAllowed = $false + break + } + } + + if ($isAllowed) { + return $true + } + } + + return $false +} + +function Resolve-BudgetTargetSubscriptionId { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues, + + [hashtable]$SubscriptionMetadata + ) + + $foundationSubscriptionId = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name 'foundationSubscriptionId' + $managementSubscriptionId = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name 'managementSubscriptionId' + + switch ([string]$ScenarioDefinition.id) { + 'foundation' { + if ($null -ne $SubscriptionMetadata -and $SubscriptionMetadata.ContainsKey('id') -and -not [string]::IsNullOrWhiteSpace([string]$SubscriptionMetadata.id)) { + return [string]$SubscriptionMetadata.id + } + + return $foundationSubscriptionId + } + 'expanded-platform' { + return $managementSubscriptionId + } + default { + return '' + } + } +} + +function Resolve-BudgetWritePreflightState { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterDocument, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues, + + [hashtable]$SubscriptionMetadata + ) + + $result = [ordered]@{ + targetSubscriptionId = '' + attempted = $false + confirmed = $false + source = 'not-applicable' + message = '' + } + + $monthlyBudgetAmount = if ($ParameterValues.ContainsKey('monthlyBudgetAmount') -and $null -ne $ParameterValues['monthlyBudgetAmount']) { + [int]$ParameterValues['monthlyBudgetAmount'] + } + else { + 0 + } + + $budgetContactEmails = Get-ArrayParameterValue -ParameterValues $ParameterValues -Name 'budgetContactEmails' + if ($monthlyBudgetAmount -le 0 -or $budgetContactEmails.Count -eq 0) { + $result.source = 'not-requested' + return $result + } + + $targetSubscriptionId = Resolve-BudgetTargetSubscriptionId -ScenarioDefinition $ScenarioDefinition -ParameterValues $ParameterValues -SubscriptionMetadata $SubscriptionMetadata + if ([string]::IsNullOrWhiteSpace($targetSubscriptionId)) { + $result.source = 'not-in-scope' + return $result + } + + $result.targetSubscriptionId = $targetSubscriptionId + $result.attempted = $true + $budgetTargetLabel = if ([string]$ScenarioDefinition.id -eq 'expanded-platform') { 'Expanded Platform management subscription' } else { 'Foundation subscription' } + + try { + $permissionEntries = Get-ScopePermissions -Scope ("subscriptions/{0}" -f $targetSubscriptionId) + $budgetWriteAccessConfirmed = Test-ScopePermissionAvailable -PermissionEntries $permissionEntries -ActionName 'Microsoft.Consumption/budgets/write' + + $result.confirmed = $budgetWriteAccessConfirmed + $result.source = 'automatic' + $result.message = if ($budgetWriteAccessConfirmed) { + "Budget write permission was confirmed automatically for $budgetTargetLabel '$targetSubscriptionId'." + } + else { + "Budget write permission (Microsoft.Consumption/budgets/write) is NOT currently available at $budgetTargetLabel '$targetSubscriptionId' for the deployment identity. The deployment will fail on the budget step. Either grant Cost Management Contributor (or Contributor / Owner) at that subscription scope, or set monthlyBudgetAmount to 0 to skip the budget step. Newly created subscriptions may need up to 48 hours before Cost Management budget creation is available." + } + } + catch { + $result.source = 'fallback' + $result.message = "Budget permission preflight could not be completed automatically for $budgetTargetLabel '$targetSubscriptionId'. The deployment will fail on the budget step if Microsoft.Consumption/budgets/write is missing. $($_.Exception.Message)" + } + + return $result +} + +function New-AuthorizationPermissionRequirement { + param( + [Parameter(Mandatory = $true)] + [string]$Scope, + + [Parameter(Mandatory = $true)] + [string]$DisplayName, + + [Parameter(Mandatory = $true)] + [string[]]$ActionNames + ) + + $normalizedScope = $Scope.Trim() + if ($normalizedScope.StartsWith('/')) { + $normalizedScope = $normalizedScope.Substring(1) + } + + return [ordered]@{ + scope = $normalizedScope + displayName = $DisplayName + actionNames = @($ActionNames) + } +} + +function Get-AuthorizationPreflightSubscriptionId { + param( + [AllowNull()] + [object]$SubscriptionMetadata, + + [string]$FallbackSubscriptionId = '' + ) + + if ($SubscriptionMetadata -is [hashtable]) { + foreach ($key in @('id', 'subscriptionId')) { + if ($SubscriptionMetadata.ContainsKey($key) -and -not [string]::IsNullOrWhiteSpace([string]$SubscriptionMetadata[$key])) { + return [string]$SubscriptionMetadata[$key] + } + } + } + + return $FallbackSubscriptionId +} + +function Get-AuthorizationPreflightManagementGroupScope { + param( + [AllowNull()] + [object]$ManagementGroupMetadata, + + [string]$FallbackManagementGroupId = '' + ) + + if ($ManagementGroupMetadata -is [hashtable]) { + if ($ManagementGroupMetadata.ContainsKey('id') -and -not [string]::IsNullOrWhiteSpace([string]$ManagementGroupMetadata.id)) { + return [string]$ManagementGroupMetadata.id + } + + if ($ManagementGroupMetadata.ContainsKey('name') -and -not [string]::IsNullOrWhiteSpace([string]$ManagementGroupMetadata.name)) { + return "providers/Microsoft.Management/managementGroups/{0}" -f [string]$ManagementGroupMetadata.name + } + } + + if ([string]::IsNullOrWhiteSpace($FallbackManagementGroupId)) { + return '' + } + + $trimmedManagementGroupId = $FallbackManagementGroupId.Trim() + if ($trimmedManagementGroupId.StartsWith('/')) { + $trimmedManagementGroupId = $trimmedManagementGroupId.Substring(1) + } + + if ($trimmedManagementGroupId.StartsWith('providers/Microsoft.Management/managementGroups/', [System.StringComparison]::OrdinalIgnoreCase)) { + return $trimmedManagementGroupId + } + + return "providers/Microsoft.Management/managementGroups/{0}" -f $trimmedManagementGroupId +} + +function Merge-AuthorizationPermissionRequirements { + param( + [Parameter(Mandatory = $true)] + [object[]]$Requirements + ) + + $requirementMap = [ordered]@{} + foreach ($requirement in $Requirements) { + if ($requirement -isnot [System.Collections.IDictionary]) { + continue + } + + $scope = [string]$requirement.scope + if ([string]::IsNullOrWhiteSpace($scope)) { + continue + } + + if (-not $requirementMap.Contains($scope)) { + $requirementMap[$scope] = [ordered]@{ + scope = $scope + displayName = [string]$requirement.displayName + actionNames = @() + } + } + + foreach ($actionName in @($requirement.actionNames)) { + if ([string]::IsNullOrWhiteSpace([string]$actionName)) { + continue + } + + $existingActionNames = @($requirementMap[$scope].actionNames) + if ($existingActionNames -notcontains [string]$actionName) { + $requirementMap[$scope].actionNames = $existingActionNames + [string]$actionName + } + } + } + + return @($requirementMap.Values) +} + +function Get-AuthorizationPreflightRequirements { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues, + + [AllowNull()] + [hashtable]$SubscriptionMetadata, + + [object[]]$AccessibleSubscriptions = @(), + + [object[]]$AccessibleManagementGroups = @() + ) + + $requirements = @() + $subscriptionAuthorizationActions = @( + 'Microsoft.Authorization/policyAssignments/write', + 'Microsoft.Authorization/roleAssignments/write' + ) + + switch ([string]$ScenarioDefinition.id) { + 'foundation' { + $targetSubscriptionId = Get-AuthorizationPreflightSubscriptionId -SubscriptionMetadata $SubscriptionMetadata -FallbackSubscriptionId (Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name 'foundationSubscriptionId') + if (-not [string]::IsNullOrWhiteSpace($targetSubscriptionId)) { + $requirements += New-AuthorizationPermissionRequirement -Scope ("subscriptions/{0}" -f $targetSubscriptionId) -DisplayName "Foundation subscription '$targetSubscriptionId'" -ActionNames $subscriptionAuthorizationActions + } + } + 'expanded-platform' { + $platformManagementGroupId = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name 'platformManagementGroupId' + $subscriptionActions = @($subscriptionAuthorizationActions) + 'Microsoft.Authorization/policySetDefinitions/write' + + $parameterSubscriptionNames = @('managementSubscriptionId', 'connectivitySubscriptionId') + if ($ScenarioDefinition.ContainsKey('requires') -and $null -ne $ScenarioDefinition.requires) { + $requires = [hashtable]$ScenarioDefinition.requires + if ($requires.ContainsKey('parameterSubscriptions') -and $null -ne $requires.parameterSubscriptions) { + $parameterSubscriptionNames = @($requires.parameterSubscriptions) + } + } + + $resolvedSubscriptions = @($AccessibleSubscriptions) + for ($index = 0; $index -lt $parameterSubscriptionNames.Count; $index++) { + $parameterName = [string]$parameterSubscriptionNames[$index] + $subscriptionMetadata = if ($index -lt $resolvedSubscriptions.Count) { $resolvedSubscriptions[$index] } else { $null } + $fallbackSubscriptionId = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name $parameterName + $subscriptionId = Get-AuthorizationPreflightSubscriptionId -SubscriptionMetadata $subscriptionMetadata -FallbackSubscriptionId $fallbackSubscriptionId + + if ([string]::IsNullOrWhiteSpace($subscriptionId)) { + continue + } + + $subscriptionLabel = switch ($parameterName) { + 'managementSubscriptionId' { 'management subscription' } + 'connectivitySubscriptionId' { 'connectivity subscription' } + default { $parameterName } + } + + $requirements += New-AuthorizationPermissionRequirement -Scope ("subscriptions/{0}" -f $subscriptionId) -DisplayName ("{0} '{1}'" -f $subscriptionLabel, $subscriptionId) -ActionNames $subscriptionActions + } + + if (-not [string]::IsNullOrWhiteSpace($platformManagementGroupId)) { + $resolvedManagementGroups = @($AccessibleManagementGroups) + $managementGroupMetadata = if ($resolvedManagementGroups.Count -gt 0) { $resolvedManagementGroups[0] } else { $null } + $managementGroupScope = Get-AuthorizationPreflightManagementGroupScope -ManagementGroupMetadata $managementGroupMetadata -FallbackManagementGroupId $platformManagementGroupId + if (-not [string]::IsNullOrWhiteSpace($managementGroupScope)) { + $requirements += New-AuthorizationPermissionRequirement -Scope $managementGroupScope -DisplayName ("Platform management group '{0}'" -f $platformManagementGroupId) -ActionNames @( + 'Microsoft.Authorization/policyAssignments/write', + 'Microsoft.Authorization/policySetDefinitions/write' + ) + } + } + } + } + + return Merge-AuthorizationPermissionRequirements -Requirements $requirements +} + +function Resolve-AuthorizationPreflightState { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues, + + [AllowNull()] + [hashtable]$SubscriptionMetadata, + + [object[]]$AccessibleSubscriptions = @(), + + [object[]]$AccessibleManagementGroups = @() + ) + + $result = [ordered]@{ + attempted = $false + confirmed = $false + source = 'not-applicable' + checkedScopes = @() + missingPermissions = @() + message = '' + } + + $requirements = @(Get-AuthorizationPreflightRequirements -ScenarioDefinition $ScenarioDefinition -ParameterValues $ParameterValues -SubscriptionMetadata $SubscriptionMetadata -AccessibleSubscriptions $AccessibleSubscriptions -AccessibleManagementGroups $AccessibleManagementGroups) + if ($requirements.Count -eq 0) { + return $result + } + + $result.attempted = $true + + try { + foreach ($requirement in $requirements) { + $permissionEntries = Get-ScopePermissions -Scope ([string]$requirement.scope) + $checkedActions = @() + + foreach ($actionName in @($requirement.actionNames)) { + $available = Test-ScopePermissionAvailable -PermissionEntries $permissionEntries -ActionName ([string]$actionName) + $checkedActions += [ordered]@{ + action = [string]$actionName + available = $available + } + + if (-not $available) { + $result.missingPermissions += [ordered]@{ + scope = [string]$requirement.scope + displayName = [string]$requirement.displayName + action = [string]$actionName + } + } + } + + $result.checkedScopes += [ordered]@{ + scope = [string]$requirement.scope + displayName = [string]$requirement.displayName + checkedActions = $checkedActions + } + } + } + catch { + $result.source = 'fallback' + $result.message = "Authorization permission preflight could not be completed automatically. The deployment identity still needs Owner or equivalent custom access for Azure Policy and Azure RBAC artifacts. $($_.Exception.Message)" + return $result + } + + $result.source = 'automatic' + $result.confirmed = @($result.missingPermissions).Count -eq 0 + $scopeNames = @($requirements | ForEach-Object { [string]$_.displayName }) + $result.message = if ($result.confirmed) { + "Authorization permissions were confirmed automatically for: {0}." -f ($scopeNames -join ', ') + } + else { + $missingPermissionText = @($result.missingPermissions | ForEach-Object { "{0}: {1}" -f [string]$_.displayName, [string]$_.action }) + "Authorization permission preflight detected missing permissions for the deployment identity. Grant Owner or equivalent custom access before running create. Missing: {0}." -f ($missingPermissionText -join '; ') + } + + return $result +} + +function Get-NetworkingWarnings { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues + ) + + $warnings = @() + $highCostWarnings = @() + + if (-not $ScenarioDefinition.ContainsKey('warnings')) { + return @{ + warnings = $warnings + highCostWarnings = $highCostWarnings + } + } + + foreach ($warning in $ScenarioDefinition.warnings) { + if (Test-WarningParameterActive -ParameterValues $ParameterValues -Name $warning.parameter) { + if ($warning.requiresExplicitApproval) { + $highCostWarnings += $warning.message + } + else { + $warnings += $warning.message + } + } + } + + return @{ + warnings = $warnings + highCostWarnings = $highCostWarnings + } +} + +function Resolve-TenantScenarioRequirements { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ScenarioDefinition, + + [Parameter(Mandatory = $true)] + [hashtable]$ParameterValues + ) + + $displayName = [string]$ScenarioDefinition.displayName + $accessibleSubscriptions = @() + $accessibleManagementGroups = @() + $validatedResources = @() + $requires = if ($ScenarioDefinition.ContainsKey('requires') -and $null -ne $ScenarioDefinition.requires) { + [hashtable]$ScenarioDefinition.requires + } + else { + @{} + } + + $requiredParameters = @() + if ($requires.ContainsKey('requiredParameters') -and $null -ne $requires.requiredParameters) { + $requiredParameters = @($requires.requiredParameters) + } + + foreach ($parameterName in $requiredParameters) { + $parameterValue = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name ([string]$parameterName) + if ([string]::IsNullOrWhiteSpace($parameterValue)) { + throw "$displayName requires parameter '$parameterName' in the effective parameter set." + } + } + + if ($requires.ContainsKey('requiredParameterValues') -and $null -ne $requires.requiredParameterValues) { + foreach ($parameterEntry in ([hashtable]$requires.requiredParameterValues).GetEnumerator()) { + $parameterName = [string]$parameterEntry.Key + $expectedValue = $parameterEntry.Value + $hasValue = $ParameterValues.ContainsKey($parameterName) + $actualValue = if ($hasValue) { $ParameterValues[$parameterName] } else { $null } + + if (-not $hasValue -or $actualValue -ne $expectedValue) { + throw "$displayName requires parameter '$parameterName' to be '$expectedValue'." + } + } + } + + $parameterSubscriptionNames = @() + if ($requires.ContainsKey('parameterSubscriptions') -and $null -ne $requires.parameterSubscriptions) { + $parameterSubscriptionNames = @($requires.parameterSubscriptions) + } + + foreach ($parameterName in $parameterSubscriptionNames) { + $parameterValue = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name ([string]$parameterName) + if ([string]::IsNullOrWhiteSpace($parameterValue)) { + throw "$displayName requires parameter '$parameterName' in the effective parameter set." + } + + $accessibleSubscriptions += Resolve-SubscriptionReference -SubscriptionReference $parameterValue + } + + $parameterManagementGroupNames = @() + if ($requires.ContainsKey('parameterManagementGroups') -and $null -ne $requires.parameterManagementGroups) { + $parameterManagementGroupNames = @($requires.parameterManagementGroups) + } + + foreach ($parameterName in $parameterManagementGroupNames) { + $parameterValue = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name ([string]$parameterName) + if (-not [string]::IsNullOrWhiteSpace($parameterValue)) { + $accessibleManagementGroups += Resolve-ManagementGroupReference -ManagementGroupReference $parameterValue + } + } + + $parameterResourceIdNames = @() + if ($requires.ContainsKey('parameterResourceIds') -and $null -ne $requires.parameterResourceIds) { + $parameterResourceIdNames = @($requires.parameterResourceIds) + } + + foreach ($parameterName in $parameterResourceIdNames) { + $parameterValue = Get-OptionalStringParameterValue -ParameterValues $ParameterValues -Name ([string]$parameterName) + if (-not [string]::IsNullOrWhiteSpace($parameterValue)) { + $validatedResources += Resolve-ResourceReference -ResourceId $parameterValue + } + } + + return @{ + accessibleSubscriptions = $accessibleSubscriptions + accessibleManagementGroups = $accessibleManagementGroups + validatedResources = $validatedResources + } +} + +function Invoke-AzCommand { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [Parameter(Mandatory = $true)] + [string]$CommandLabel, + + [Parameter(Mandatory = $true)] + [string]$OutputDirectory + ) + + $commandLine = Format-CommandLine -Arguments $Arguments + $commandFile = Join-Path $OutputDirectory ("{0}.command.txt" -f $CommandLabel) + $rawFile = Join-Path $OutputDirectory ("{0}.raw.txt" -f $CommandLabel) + $jsonFile = Join-Path $OutputDirectory ("{0}.json" -f $CommandLabel) + + Set-Content -Path $commandFile -Value $commandLine -Encoding UTF8 + Write-InstallerLog -Message $commandLine + + $rawOutput = & az @Arguments 2>&1 | Out-String + Set-Content -Path $rawFile -Value $rawOutput -Encoding UTF8 + + if ($LASTEXITCODE -ne 0) { + throw "Azure CLI command failed during $CommandLabel. See $rawFile for details." + } + + if ([string]::IsNullOrWhiteSpace($rawOutput)) { + return $null + } + + try { + $parsed = $rawOutput | ConvertFrom-Json -AsHashtable -Depth 100 + Write-JsonDocument -Path $jsonFile -Value $parsed + return $parsed + } + catch { + Set-Content -Path $jsonFile -Value $rawOutput -Encoding UTF8 + return $rawOutput + } +} + +function Get-WhatIfSummary { + param( + [object]$WhatIfResult + ) + + $summary = @{} + if ($WhatIfResult -is [hashtable] -and $WhatIfResult.ContainsKey('changes')) { + foreach ($change in $WhatIfResult.changes) { + $changeType = [string]$change.changeType + if ([string]::IsNullOrWhiteSpace($changeType)) { + continue + } + + if (-not $summary.ContainsKey($changeType)) { + $summary[$changeType] = 0 + } + + $summary[$changeType]++ + } + + return $summary + } + + if ($WhatIfResult -is [string] -and -not [string]::IsNullOrWhiteSpace($WhatIfResult)) { + $resourceLines = [regex]::Matches($WhatIfResult, '(?m)^ (?[+~=\-]) [^\r\n]+\[[^\r\n]+\]\s*$') + foreach ($resourceLine in $resourceLines) { + $changeType = switch ($resourceLine.Groups['symbol'].Value) { + '+' { 'Create' } + '~' { 'Modify' } + '-' { 'Delete' } + '=' { 'NoChange' } + default { '' } + } + + if ([string]::IsNullOrWhiteSpace($changeType)) { + continue + } + + if (-not $summary.ContainsKey($changeType)) { + $summary[$changeType] = 0 + } + + $summary[$changeType]++ + } + } + + return $summary +} + +function Confirm-CreateApproval { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ResolvedConfig + ) + + $effectiveNonInteractive = $NonInteractive.IsPresent -or [bool]$ResolvedConfig.effectiveFlags.nonInteractive + $effectiveAutoApprove = $AutoApprove.IsPresent -or [bool]$ResolvedConfig.effectiveFlags.autoApprove + + $budgetPreflight = $ResolvedConfig.budgetPreflight + $budgetWillFail = $false + if ($budgetPreflight -is [System.Collections.IDictionary]) { + $attempted = [bool]$budgetPreflight.attempted + $confirmed = [bool]$budgetPreflight.confirmed + $source = [string]$budgetPreflight.source + # Block when we actually probed the permission and it came back negative. + # Fallback (probe could not run) stays a soft warning to avoid false positives. + if ($attempted -and $source -eq 'automatic' -and -not $confirmed) { + $budgetWillFail = $true + } + } + + if ($budgetWillFail) { + if (-not $effectiveAutoApprove) { + throw 'Budget preflight failed: Microsoft.Consumption/budgets/write is not available for the deployment identity at the target budget subscription scope. The deployment would fail on the budget step after most platform resources are already created. Set monthlyBudgetAmount to 0 to skip the budget step, grant Cost Management Contributor (or Contributor / Owner) on the subscription, or re-run with AutoApprove to acknowledge the risk.' + } + + Write-InstallerLog -Message 'Proceeding with create even though the budget preflight failed because AutoApprove was supplied. The deployment will still fail on the budget step unless permissions or subscription readiness change.' -Level 'WARNING' + } + + $authorizationPreflight = $ResolvedConfig.authorizationPreflight + if ($authorizationPreflight -is [System.Collections.IDictionary]) { + $authorizationAttempted = [bool]$authorizationPreflight.attempted + $authorizationConfirmed = [bool]$authorizationPreflight.confirmed + $authorizationSource = [string]$authorizationPreflight.source + if ($authorizationAttempted -and $authorizationSource -eq 'automatic' -and -not $authorizationConfirmed) { + $authorizationMessage = [string]$authorizationPreflight.message + if ([string]::IsNullOrWhiteSpace($authorizationMessage)) { + $authorizationMessage = 'Grant Owner or equivalent custom access for Azure Policy and Azure RBAC role assignments before running create.' + } + + throw "Authorization preflight failed: $authorizationMessage" + } + } + + if ($effectiveNonInteractive) { + if (-not $effectiveAutoApprove) { + throw 'Create requires explicit approval. Re-run with AutoApprove or set autoApprove to true in the config for non-interactive execution.' + } + + return + } + + if ($effectiveAutoApprove) { + return + } + + Write-InstallerLog -Message 'Create approval is required before deployment continues.' -Level 'WARNING' + $response = Read-Host 'Type CREATE to continue' + if ($response -cne 'CREATE') { + throw 'Create was cancelled before deployment started.' + } +} + +function New-CustomerSummary { + param( + [Parameter(Mandatory = $true)] + [hashtable]$ResolvedConfig, + + [object]$CreateResult, + + [hashtable]$WhatIfSummary + ) + + $outputs = $null + if ($CreateResult -is [hashtable] -and $CreateResult.ContainsKey('properties')) { + $properties = $CreateResult.properties + if ($properties -is [hashtable] -and $properties.ContainsKey('outputs')) { + $outputs = $properties.outputs + } + } + + $followUpActions = Get-OutputValue -Outputs $outputs -Name 'followUpActions' -DefaultValue @() + if ($followUpActions -isnot [System.Collections.IEnumerable] -or $followUpActions -is [string]) { + $followUpActions = @($followUpActions) + } + + return [ordered]@{ + scenario = $ResolvedConfig.scenarioDefinition.id + scenarioDisplayName = $ResolvedConfig.scenarioDefinition.displayName + action = $Action + deploymentName = $ResolvedConfig.deploymentName + deploymentLocation = $ResolvedConfig.deploymentLocation + deploymentScope = $ResolvedConfig.commandFamily + subscription = $ResolvedConfig.subscriptionMetadata + accessibleSubscriptions = $ResolvedConfig.accessibleSubscriptions + whatIfSummary = $WhatIfSummary + warnings = $ResolvedConfig.warnings + highCostWarnings = $ResolvedConfig.highCostWarnings + deploymentOutputs = [ordered]@{ + deploymentProfile = Get-OutputValue -Outputs $outputs -Name 'deploymentProfile' + deploymentMode = Get-OutputValue -Outputs $outputs -Name 'deploymentMode' + primarySubscriptionId = Get-OutputValue -Outputs $outputs -Name 'primarySubscriptionId' + handoverReady = Get-OutputValue -Outputs $outputs -Name 'handoverReady' -DefaultValue $false + alertResponseReady = Get-OutputValue -Outputs $outputs -Name 'alertResponseReady' -DefaultValue $false + platformResourceGroupName = Get-OutputValue -Outputs $outputs -Name 'platformResourceGroupName' + networkResourceGroupName = Get-OutputValue -Outputs $outputs -Name 'networkResourceGroupName' + logAnalyticsWorkspaceResourceId = Get-OutputValue -Outputs $outputs -Name 'logAnalyticsWorkspaceResourceId' + keyVaultResourceId = Get-OutputValue -Outputs $outputs -Name 'keyVaultResourceId' + followUpActions = $followUpActions + } + } +} + +function Show-CustomerSummary { + param( + [Parameter(Mandatory = $true)] + [hashtable]$Summary + ) + + Write-InstallerLog -Message 'Azure Landing Zone deployment summary' -Level 'SUCCESS' + Write-InstallerLog -Message ("Scenario: {0}" -f $Summary.scenarioDisplayName) + Write-InstallerLog -Message ("Action: {0}" -f $Summary.action) + Write-InstallerLog -Message ("Deployment name: {0}" -f $Summary.deploymentName) + + if ($Summary.deploymentOutputs.deploymentProfile) { + Write-InstallerLog -Message ("Profile: {0}" -f $Summary.deploymentOutputs.deploymentProfile) + } + + if ($Summary.deploymentOutputs.deploymentMode) { + Write-InstallerLog -Message ("Mode: {0}" -f $Summary.deploymentOutputs.deploymentMode) + } + + if ($Summary.deploymentOutputs.primarySubscriptionId) { + Write-InstallerLog -Message ("Primary subscription: {0}" -f $Summary.deploymentOutputs.primarySubscriptionId) + } + + if ($Summary.action -eq 'what-if' -or $Summary.action -eq 'create') { + if ($Summary.whatIfSummary.Count -gt 0) { + $parts = foreach ($entry in $Summary.whatIfSummary.GetEnumerator()) { + '{0}={1}' -f $entry.Key, $entry.Value + } + Write-InstallerLog -Message ("What-if summary: {0}" -f ($parts -join ', ')) + } + } + + if ($Summary.action -eq 'create') { + Write-InstallerLog -Message ("Operational ownership ready: {0}" -f $Summary.deploymentOutputs.handoverReady) + Write-InstallerLog -Message ("Alert response ready: {0}" -f $Summary.deploymentOutputs.alertResponseReady) + } + + foreach ($warning in $Summary.warnings) { + Write-InstallerLog -Message $warning -Level 'WARNING' + } + + foreach ($warning in $Summary.highCostWarnings) { + Write-InstallerLog -Message $warning -Level 'WARNING' + } + + foreach ($actionItem in $Summary.deploymentOutputs.followUpActions) { + if (-not [string]::IsNullOrWhiteSpace([string]$actionItem)) { + Write-InstallerLog -Message ("Action required: {0}" -f $actionItem) -Level 'WARNING' + } + } +} + +Test-RequiredTooling + +$resolvedConfigFile = Resolve-PathAgainstRoot -PathValue $ConfigFile -ConfigDirectory $PWD.Path +if (-not (Test-Path $resolvedConfigFile -PathType Leaf)) { + throw "Config file does not exist: $resolvedConfigFile" +} + +$configDirectory = Split-Path -Parent $resolvedConfigFile +$config = Read-JsonDocument -Path $resolvedConfigFile +$scenarioManifest = Read-JsonDocument -Path $script:ScenarioManifestPath +$scenarioDefinition = Resolve-ScenarioDefinition -RequestedScenario ([string]$config.scenario) -ScenarioManifest $scenarioManifest + +if ($scenarioDefinition.ContainsKey('implemented') -and -not [bool]$scenarioDefinition.implemented) { + throw $scenarioDefinition.reason +} + +$parametersPathSetting = if ($config.ContainsKey('parametersFile') -and -not [string]::IsNullOrWhiteSpace([string]$config.parametersFile)) { + [string]$config.parametersFile +} +else { + [string]$scenarioDefinition.defaultParametersFile +} + +$resolvedParametersFile = Resolve-PathAgainstRoot -PathValue $parametersPathSetting -ConfigDirectory $configDirectory +if (-not (Test-Path $resolvedParametersFile -PathType Leaf)) { + throw "Parameters file does not exist: $resolvedParametersFile" +} + +$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$defaultOutputFolder = Join-Path $script:AlzRoot ("artifacts\generated\cli\{0}-{1}" -f $scenarioDefinition.id, $timestamp) +$outputFolderSetting = if (-not [string]::IsNullOrWhiteSpace($OutputFolder)) { + $OutputFolder +} +elseif ($config.ContainsKey('outputFolder') -and -not [string]::IsNullOrWhiteSpace([string]$config.outputFolder)) { + [string]$config.outputFolder +} +else { + $defaultOutputFolder +} + +$resolvedOutputFolder = Resolve-PathAgainstRoot -PathValue $outputFolderSetting -ConfigDirectory $configDirectory +New-Item -Path $resolvedOutputFolder -ItemType Directory -Force | Out-Null + +$script:LogFile = Join-Path $resolvedOutputFolder 'installer.log' + +Write-InstallerLog -Message 'Azure Landing Zone CLI installer started.' +Write-InstallerLog -Message ("Config file: {0}" -f $resolvedConfigFile) +Write-InstallerLog -Message ("Requested scenario: {0}" -f $scenarioDefinition.displayName) +Write-InstallerLog -Message ("Action: {0}" -f $Action) + +$parameterDocument = Read-JsonDocument -Path $resolvedParametersFile +if (-not $parameterDocument.ContainsKey('parameters')) { + throw 'The selected parameters file does not contain a parameters object.' +} + +$parameterOverrides = @{} +if ($config.ContainsKey('parameterOverrides') -and $null -ne $config.parameterOverrides) { + $parameterOverrides = [hashtable]$config.parameterOverrides +} + +foreach ($override in $parameterOverrides.GetEnumerator()) { + $parameterDocument.parameters[$override.Key] = @{ + value = $override.Value + } +} + +$effectiveParameterValues = Get-EffectiveParameterValues -ParameterDocument $parameterDocument +Assert-ServiceOwnerParameterValue -ParameterValues $effectiveParameterValues + +$scenarioDefinition = Resolve-ScenarioMode -ScenarioDefinition $scenarioDefinition -ParameterValues $effectiveParameterValues +Write-InstallerLog -Message ("Resolved deployment path: {0}" -f $scenarioDefinition.displayName) + +$resolvedEntryPoint = Resolve-PathAgainstRoot -PathValue ([string]$scenarioDefinition.entryPoint) -ConfigDirectory $configDirectory +if (-not (Test-Path $resolvedEntryPoint -PathType Leaf)) { + throw "Template file does not exist: $resolvedEntryPoint" +} + +$effectiveValidationLevel = if (-not [string]::IsNullOrWhiteSpace($ValidationLevel)) { + $ValidationLevel +} +elseif ($config.ContainsKey('validationLevel') -and -not [string]::IsNullOrWhiteSpace([string]$config.validationLevel)) { + [string]$config.validationLevel +} +else { + 'Provider' +} + +$deploymentLocation = if ($config.ContainsKey('deploymentLocation') -and -not [string]::IsNullOrWhiteSpace([string]$config.deploymentLocation)) { + [string]$config.deploymentLocation +} +else { + throw 'deploymentLocation is required in the config file.' +} + +$deploymentName = if ($config.ContainsKey('deploymentName') -and -not [string]::IsNullOrWhiteSpace([string]$config.deploymentName)) { + [string]$config.deploymentName +} +else { + "alz-{0}-{1}" -f $scenarioDefinition.id, $timestamp.ToLowerInvariant() +} + +$subscriptionMetadata = $null +$accessibleSubscriptions = @() +$accessibleManagementGroups = @() +$validatedResources = @() +$commandFamily = [string]$scenarioDefinition.commandFamily + +switch ($commandFamily) { + 'sub' { + if (-not $config.ContainsKey('scope') -or $null -eq $config.scope -or -not $config.scope.ContainsKey('subscription') -or [string]::IsNullOrWhiteSpace([string]$config.scope.subscription)) { + throw "$($scenarioDefinition.displayName) requires scope.subscription in the config file." + } + + $subscriptionMetadata = Resolve-SubscriptionReference -SubscriptionReference ([string]$config.scope.subscription) + $accessibleSubscriptions += $subscriptionMetadata + } + 'tenant' { + $tenantScenarioRequirements = Resolve-TenantScenarioRequirements -ScenarioDefinition $scenarioDefinition -ParameterValues $effectiveParameterValues + $accessibleSubscriptions += $tenantScenarioRequirements.accessibleSubscriptions + $accessibleManagementGroups += $tenantScenarioRequirements.accessibleManagementGroups + $validatedResources += $tenantScenarioRequirements.validatedResources + } + default { + throw "Unsupported command family '$commandFamily'." + } +} + +$budgetPreflight = Resolve-BudgetWritePreflightState -ScenarioDefinition $scenarioDefinition -ParameterDocument $parameterDocument -ParameterValues $effectiveParameterValues -SubscriptionMetadata $subscriptionMetadata +$authorizationPreflight = Resolve-AuthorizationPreflightState -ScenarioDefinition $scenarioDefinition -ParameterValues $effectiveParameterValues -SubscriptionMetadata $subscriptionMetadata -AccessibleSubscriptions $accessibleSubscriptions -AccessibleManagementGroups $accessibleManagementGroups +$effectiveParametersFile = Join-Path $resolvedOutputFolder 'effective.parameters.json' +Write-JsonDocument -Path $effectiveParametersFile -Value $parameterDocument +$effectiveParameterValues = Get-EffectiveParameterValues -ParameterDocument $parameterDocument + +if (-not [string]::IsNullOrWhiteSpace([string]$budgetPreflight.message)) { + $budgetPreflightLevel = if ($budgetPreflight.source -eq 'fallback' -or ($budgetPreflight.source -eq 'automatic' -and -not [bool]$budgetPreflight.confirmed)) { + 'WARNING' + } + else { + 'INFO' + } + + Write-InstallerLog -Message ([string]$budgetPreflight.message) -Level $budgetPreflightLevel +} + +if (-not [string]::IsNullOrWhiteSpace([string]$authorizationPreflight.message)) { + $authorizationPreflightLevel = if ($authorizationPreflight.source -eq 'fallback' -or ($authorizationPreflight.source -eq 'automatic' -and -not [bool]$authorizationPreflight.confirmed)) { + 'WARNING' + } + else { + 'INFO' + } + + Write-InstallerLog -Message ([string]$authorizationPreflight.message) -Level $authorizationPreflightLevel +} + +$warningState = Get-NetworkingWarnings -ScenarioDefinition $scenarioDefinition -ParameterValues $effectiveParameterValues + +$resolvedState = [ordered]@{ + scenarioDefinition = $scenarioDefinition + resolvedConfigFile = $resolvedConfigFile + resolvedEntryPoint = $resolvedEntryPoint + resolvedParametersFile = $resolvedParametersFile + effectiveParametersFile = $effectiveParametersFile + resolvedOutputFolder = $resolvedOutputFolder + deploymentLocation = $deploymentLocation + deploymentName = $deploymentName + validationLevel = $effectiveValidationLevel + commandFamily = $commandFamily + subscriptionMetadata = $subscriptionMetadata + accessibleSubscriptions = $accessibleSubscriptions + accessibleManagementGroups = $accessibleManagementGroups + validatedResources = $validatedResources + budgetPreflight = $budgetPreflight + authorizationPreflight = $authorizationPreflight + warnings = @($warningState.warnings) + highCostWarnings = @($warningState.highCostWarnings) + effectiveFlags = [ordered]@{ + nonInteractive = $config.ContainsKey('nonInteractive') -and [bool]$config.nonInteractive + autoApprove = $config.ContainsKey('autoApprove') -and [bool]$config.autoApprove + } +} + +Write-JsonDocument -Path (Join-Path $resolvedOutputFolder 'resolved-config.json') -Value ([ordered]@{ + configFile = $resolvedConfigFile + scenario = $scenarioDefinition.id + scenarioMode = if ($scenarioDefinition.ContainsKey('modeId')) { [string]$scenarioDefinition.modeId } else { [string]$scenarioDefinition.id } + displayName = $scenarioDefinition.displayName + deploymentName = $deploymentName + deploymentLocation = $deploymentLocation + validationLevel = $effectiveValidationLevel + commandFamily = $commandFamily + templateFile = $resolvedEntryPoint + sourceParametersFile = $resolvedParametersFile + effectiveParametersFile = $effectiveParametersFile + warnings = $resolvedState.warnings + highCostWarnings = $resolvedState.highCostWarnings + budgetPreflight = $budgetPreflight + authorizationPreflight = $authorizationPreflight + accessibleSubscriptions = $accessibleSubscriptions + accessibleManagementGroups = $accessibleManagementGroups + validatedResources = $validatedResources +}) + +if ($resolvedState.warnings.Count -gt 0) { + foreach ($warning in $resolvedState.warnings) { + Write-InstallerLog -Message $warning -Level 'WARNING' + } +} + +if ($resolvedState.highCostWarnings.Count -gt 0) { + foreach ($warning in $resolvedState.highCostWarnings) { + Write-InstallerLog -Message $warning -Level 'WARNING' + } +} + +$commandPrefix = @('deployment', $commandFamily) +$commonArguments = @( + '--name', $deploymentName, + '--location', $deploymentLocation, + '--template-file', $resolvedEntryPoint, + '--parameters', "@$effectiveParametersFile", + '--validation-level', $effectiveValidationLevel, + '--only-show-errors', + '-o', 'json' +) + +if ($commandFamily -eq 'sub') { + $commonArguments += @('--subscription', [string]$subscriptionMetadata.id) +} + +$validateArguments = $commandPrefix + @('validate') + $commonArguments +$null = Invoke-AzCommand -Arguments $validateArguments -CommandLabel 'validate' -OutputDirectory $resolvedOutputFolder + +$whatIfResult = $null +$whatIfSummary = @{} +if ($Action -eq 'what-if' -or $Action -eq 'create') { + $whatIfArguments = $commandPrefix + @('what-if') + $commonArguments + @('--result-format', 'FullResourcePayloads') + $whatIfResult = Invoke-AzCommand -Arguments $whatIfArguments -CommandLabel 'what-if' -OutputDirectory $resolvedOutputFolder + $whatIfSummary = Get-WhatIfSummary -WhatIfResult $whatIfResult +} + +$createResult = $null +if ($Action -eq 'create') { + Confirm-CreateApproval -ResolvedConfig $resolvedState + $createArguments = $commandPrefix + @('create') + $commonArguments + # --mode is only valid for resource-group scope deployments. Subscription, management + # group, and tenant scope deployments are always incremental and reject the flag. + if ($commandFamily -eq 'group') { + $createArguments += @('--mode', 'Incremental') + } + $createResult = Invoke-AzCommand -Arguments $createArguments -CommandLabel 'create' -OutputDirectory $resolvedOutputFolder +} + +$summary = New-CustomerSummary -ResolvedConfig $resolvedState -CreateResult $createResult -WhatIfSummary $whatIfSummary +Write-JsonDocument -Path (Join-Path $resolvedOutputFolder 'summary.json') -Value $summary +Show-CustomerSummary -Summary $summary + +Write-InstallerLog -Message ("Artifacts written to: {0}" -f $resolvedOutputFolder) -Level 'SUCCESS' \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/README.md b/AzureLandingZoneforNonprofits/README.md index 766d4657..7af93ae2 100644 --- a/AzureLandingZoneforNonprofits/README.md +++ b/AzureLandingZoneforNonprofits/README.md @@ -1,13 +1,39 @@ -# Azure Landing Zone +# Azure Landing Zone CLI Installer -Azure Landing Zone for Nonprofits is a preconfigured cloud environment that uses best practices to help smaller nonprofits get set up on Azure efficiently and securely. This guided setup provides nonprofits with a blueprint for cloud adoption, setting up core Azure services for networking, management, identity, and security. This simplified process enables organizations to quickly deploy cloud environments built on best practices for scalability, security, and compliance, making it easier to migrate, modernize, and build a foundation for AI innovation. +Use this package to deploy Azure Landing Zone Foundation or Expanded Platform scenarios from a command line. The package is self-contained; keep the folder structure intact when copying or extracting it. -Learn More About: -- [Azure Landing Zone for Nonprofits](https://learn.microsoft.com/en-us/industry/nonprofit/azure-landing-zone) -- [Azure Landing Zones](https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/landing-zone/) +## What's Included -## Deploy -[Deploy Azure Landing Zone for Nonprofits](../Documents/alz-build-and-deploy.md). +- `Install-AzureLandingZone.ps1`: PowerShell installer that runs Azure CLI deployment commands. +- `examples/commands/`: starter configuration files for each supported scenario. +- `examples/parameters/`: example deployment parameter files referenced by the starter configs. +- `infra/`: deployment templates used by the installer. +- `scenarios.json`: supported scenario catalog used by the installer. -## Use -Get your staff and organization ready for Azure Landing Zones with [readiness and skilling](https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/suggested-skills). +## Prerequisites + +- PowerShell 7 or later. +- Azure CLI 2.76.0 or later. +- Azure CLI Bicep support (`az bicep version` must work). +- An authenticated Azure CLI session with access to the target tenant and subscriptions. +- Sufficient Azure permissions for the selected scenario and deployment scope. + +## Validate A Deployment + +Run the following command from the package root: + +```powershell +pwsh ./Install-AzureLandingZone.ps1 ` + -ConfigFile ./examples/commands/foundation.install-config.json ` + -Action validate +``` + +## Deployment Flow + +1. Copy one of the starter config files from `examples/commands/`. +2. Fill in the target subscription IDs, deployment prefix, locations, notification values, and scenario-specific settings. +3. Run `validate` to check the configuration and prerequisites. +4. Run `what-if` to preview the Azure changes. +5. Run `create` when the preview is acceptable. + +Generated logs and deployment outputs are written under the `outputFolder` configured in the selected install config. diff --git a/AzureLandingZoneforNonprofits/cli-package-manifest.json b/AzureLandingZoneforNonprofits/cli-package-manifest.json new file mode 100644 index 00000000..c413605b --- /dev/null +++ b/AzureLandingZoneforNonprofits/cli-package-manifest.json @@ -0,0 +1,38 @@ +{ + "schemaVersion": "1.0", + "packageName": "azure-landing-zone-cli", + "publicSurface": "cli-package", + "generatedAtUtc": "2026-05-20T07:38:11.8913138Z", + "includedItems": [ + { + "sourcePath": "cli/Install-AzureLandingZone.ps1", + "packagePath": "Install-AzureLandingZone.ps1" + }, + { + "sourcePath": "infra/entrypoints/direct/", + "packagePath": "infra/entrypoints/direct" + }, + { + "sourcePath": "infra/modules/", + "packagePath": "infra/modules" + }, + { + "sourcePath": "cli/examples/parameters/", + "packagePath": "examples/parameters" + }, + { + "sourcePath": "cli/examples/commands/", + "packagePath": "examples/commands" + }, + { + "sourcePath": "cli/scenarios.json", + "packagePath": "scenarios.json", + "transform": "package-layout" + }, + { + "sourcePath": "tools/package/Build-CliPackage.ps1", + "packagePath": "README.md", + "transform": "generated-package-readme" + } + ] +} diff --git a/AzureLandingZoneforNonprofits/core/managementGroupTemplates/mgmtGroupStructure/mgmtGroups.json b/AzureLandingZoneforNonprofits/core/managementGroupTemplates/mgmtGroupStructure/mgmtGroups.json deleted file mode 100644 index 921a57c9..00000000 --- a/AzureLandingZoneforNonprofits/core/managementGroupTemplates/mgmtGroupStructure/mgmtGroups.json +++ /dev/null @@ -1,32 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "topLevelManagementGroupPrefix": { - "type": "string", - "metadata": { - "description": "Provide prefix for the management group structure." - } - }, - "location": { - "type": "string", - "defaultValue": "[deployment().location]", - "metadata": { - "description": "Provide location for the management group structure." - } - } - }, - "variables": { - }, - "resources": [ - { - "type": "Microsoft.Management/managementGroups", - "scope": "/", - "apiVersion": "2020-05-01", - "name": "[parameters('topLevelManagementGroupPrefix')]", - "properties": {} - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/managementGroupTemplates/roleDefinitions/Custom-RBACDefinitions.json b/AzureLandingZoneforNonprofits/core/managementGroupTemplates/roleDefinitions/Custom-RBACDefinitions.json deleted file mode 100644 index 96f504bb..00000000 --- a/AzureLandingZoneforNonprofits/core/managementGroupTemplates/roleDefinitions/Custom-RBACDefinitions.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "topLevelManagementGroupPrefix": { - "type": "string", - "defaultValue": "" - } - }, - "variables": { - "roles": { - "roleDefinitions": [ - { - "properties": { - "roleName": "[concat(parameters('toplevelManagementGroupPrefix'), '-LZ Subscription Owner')]", - "description": "LZ Subscription Owner", - "type": "customRole", - "permissions": [ - { - "actions": [ - "*" - ], - "notActions": [ - "Microsoft.Blueprint/blueprintAssignments/write", - "Microsoft.Blueprint/blueprintAssignments/delete", - "Microsoft.Network/vpnGateways/*", - "Microsoft.Network/expressRouteCircuits/*", - "Microsoft.Network/routeTables/write", - "Microsoft.Network/routeTables/delete", - "Microsoft.Network/routeTables/routes/write", - "Microsoft.Network/azurefirewalls/write", - "Microsoft.Network/azurefirewalls/delete", - "Microsoft.Network/firewallPolicies/write", - "Microsoft.Network/firewallPolicies/join/action", - "Microsoft.Network/firewallPolicies/delete", - "Microsoft.Network/firewallPolicies/ruleGroups/write", - "Microsoft.Network/firewallPolicies/ruleGroups/delete", - "Microsoft.Network/vpnSites/*", - "Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies/*", - "Microsoft.Network/networkSecurityGroups/securityRules/delete", - "Microsoft.Network/networkSecurityGroups/delete", - "Microsoft.Network/virtualNetworks/write", - "Microsoft.Network/virtualNetworks/delete" - ], - "dataActions": [], - "notDataActions": [] - } - ] - } - }, - { - "properties": { - "roleName": "[concat(parameters('toplevelManagementGroupPrefix'), '-Platform Contributors')]", - "description": "Custom Role that grants full access to manage all Platform resources, but does not allow you to assign roles in Azure RBAC, manage assignments in Azure Blueprints, or share image galleries", - "type": "customRole", - "permissions": [ - { - "actions": [ - "*" - ], - "notActions": [ - "Microsoft.Authorization/*/Delete", - "Microsoft.Authorization/*/Write", - "Microsoft.Authorization/elevateAccess/Action", - "Microsoft.Blueprint/blueprintAssignments/write", - "Microsoft.Blueprint/blueprintAssignments/delete", - "Microsoft.Compute/galleries/share/action" - ], - "dataActions": [], - "notDataActions": [] - } - ] - } - }, - { - "properties": { - "roleName": "[concat(parameters('toplevelManagementGroupPrefix'), '-NetOps')]", - "description": "Platform-wide global connectivity management", - "type": "customRole", - "permissions": [ - { - "actions": [ - "*/read", - "Microsoft.Authorization/*/read", - "Microsoft.Insights/alertRules/*", - "Microsoft.Network/*", - "Microsoft.ResourceHealth/availabilityStatuses/read", - "Microsoft.Resources/deployments/*", - "Microsoft.Resources/subscriptions/resourceGroups/read", - "Microsoft.Support/*" - ], - "notActions": [], - "dataActions": [], - "notDataActions": [] - } - ] - } - } - ] - } - }, - "resources": [ - { - "type": "Microsoft.Authorization/roleDefinitions", - "name": "[guid(tenantResourceId('Microsoft.Management/managementGroups/', parameters('topLevelManagementGroupPrefix')), variables('roles').roleDefinitions[copyIndex()].properties.roleName)]", - "apiVersion": "2018-01-01-preview", - "copy": { - "name": "roleDefinitionCopy", - "count": "[length(variables('roles').roleDefinitions)]" - }, - "properties": { - "roleName": "[variables('roles').roleDefinitions[copyIndex()].properties.roleName]", - "description": "[variables('roles').roleDefinitions[copyIndex()].properties.description]", - "type": "[variables('roles').roleDefinitions[copyIndex()].properties.type]", - "permissions": "[variables('roles').roleDefinitions[copyIndex()].properties.permissions]", - "assignableScopes": [ - "[concat('/providers/Microsoft.Management/managementGroups/', parameters('topLevelManagementGroupPrefix'))]" - ] - } - } - ] -} diff --git a/AzureLandingZoneforNonprofits/core/managementGroupTemplates/subscriptionOrganization/subscriptionOrganization.json b/AzureLandingZoneforNonprofits/core/managementGroupTemplates/subscriptionOrganization/subscriptionOrganization.json deleted file mode 100644 index f15bf318..00000000 --- a/AzureLandingZoneforNonprofits/core/managementGroupTemplates/subscriptionOrganization/subscriptionOrganization.json +++ /dev/null @@ -1,30 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "targetManagementGroupId": { - "type": "string", - "metadata": { - "description": "Provide the management group id (e.g. 'eslz-corp')" - } - }, - "subscriptionId": { - "type": "string", - "metadata": { - "description": "Provide the subscriptionId you will place into the management group" - } - } - }, - "resources": [ - { - "scope": "/", - "type": "Microsoft.Management/managementGroups/subscriptions", - "apiVersion": "2020-05-01", - "name": "[concat(parameters('targetManagementGroupId'), '/', parameters('subscriptionId'))]", - "properties": { - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/defenderCloud.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/defenderCloud.json deleted file mode 100644 index dcc51dae..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/defenderCloud.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "defenderRegion": { - "type": "string", - "metadata": { - "description": "Provide location for the resource group" - } - } - }, - "variables": { - "defenderDeploymentName": "[take(concat(deployment().name, '-ddos', parameters('defenderRegion')), 60)]" - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2021-04-01", - "name": "[variables('defenderDeploymentName')]", - "location": "[parameters('defenderRegion')]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - - }, - "resources": [ - { - "type": "Microsoft.Security/pricings", - "name": "VirtualMachines", - "apiVersion": "2024-01-01", - "properties": { - "pricingTier": "Standard" - } - }, - { - "type": "Microsoft.Security/pricings", - "name": "SqlServers", - "apiVersion": "2024-01-01", - "properties": { - "pricingTier": "Standard" - } - }, - { - "type": "Microsoft.Security/pricings", - "name": "AppServices", - "apiVersion": "2024-01-01", - "properties": { - "pricingTier": "Standard" - } - }, - { - "type": "Microsoft.Security/pricings", - "name": "StorageAccounts", - "apiVersion": "2024-01-01", - "properties": { - "pricingTier": "Standard" - } - }, - { - "type": "Microsoft.Security/pricings", - "name": "KeyVaults", - "apiVersion": "2024-01-01", - "properties": { - "pricingTier": "Standard" - } - }, - { - "type": "Microsoft.Security/pricings", - "name": "KubernetesService", - "apiVersion": "2024-01-01", - "properties": { - "pricingTier": "Standard" - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/enableDDoS.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/enableDDoS.json deleted file mode 100644 index d554e1da..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/enableDDoS.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "rgName": { - "type": "string", - "metadata": { - "description": "Provide name for resource group" - } - }, - "location": { - "type": "string", - "metadata": { - "description": "Provide location for the resource group" - } - }, - "ddosName": { - "type": "string", - "metadata": { - "description": "Provide a name for the DDoS protection plan" - } - } - }, - "variables": { - "ddosDeploymentName": "[take(concat(deployment().name, '-ddos', parameters('location')), 60)]" - }, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2020-10-01", - "name": "[parameters('rgName')]", - "location": "[parameters('location')]" - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2021-04-01", - "name": "[variables('ddosDeploymentName')]", - "resourceGroup": "[parameters('rgName')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/resourceGroups', parameters('rgName'))]" - ], - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - - }, - "resources": [ - { - "type": "Microsoft.Network/ddosProtectionPlans", - "apiVersion": "2019-02-01", - "name": "[parameters('ddosName')]", - "location": "[parameters('location')]", - "properties": {} - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/hubNetwork.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/hubNetwork.json deleted file mode 100644 index 4b9bfb00..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/hubNetwork.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "rgName": { - "type": "String" - }, - "hubName": { - "type": "string", - "metadata": { - "description": "Name of the Virtual Network" - } - }, - "hubAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Virtual Network" - } - }, - "hubRegion": { - "type": "String", - "defaultValue": "[deployment().location]" - }, - "hubSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the Hub Subnet" - } - }, - "hubSubnetAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Hub Subnet" - } - }, - "vpnGWSubnet": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the VPN Gateway Subnet" - } - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2018-05-01", - "name": "[parameters('rgName')]", - "location": "[deployment().location]", - "properties": {} - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2018-05-01", - "name": "hub-network", - "resourceGroup": "[parameters('rgName')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/resourceGroups/', parameters('rgName'))]" - ], - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-02-01", - "name": "[parameters('hubName')]", - "location": "[parameters('hubRegion')]", - "properties": { - "addressSpace": { - "addressPrefixes": [ - "[parameters('hubAddrPrefix')]" - ] - }, - "subnets": [ - { - "name": "[parameters('hubSubnetName')]", - "properties": { - "addressPrefix": "[parameters('hubSubnetAddrPrefix')]" - } - }, - { - "name": "GatewaySubnet", - "properties": { - "addressPrefix": "[parameters('vpnGWSubnet')]" - } - } - ] - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/keyVault.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/keyVault.json deleted file mode 100644 index 00fd2bba..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/keyVault.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "KeyVaultName": { - "type": "string", - "metadata": { - "description": "The name of the Recovery Services Vault" - } - }, - "rgName": { - "type": "string", - "metadata": { - "description": "The name of the resource group in which to deploy the Recovery Services Vault" - } - }, - "kvRegion": { - "type": "string", - "defaultValue": "[deployment().location]" - } - - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2018-05-01", - "name": "[parameters('rgName')]", - "location": "[deployment().location]", - "properties": {} - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2018-05-01", - "name": "[parameters('KeyVaultName')]", - "resourceGroup": "[parameters('rgName')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/resourceGroups/', parameters('rgName'))]" - ], - "properties": { - "mode": "Incremental", - "template": { - "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.KeyVault/vaults", - "apiVersion": "2019-09-01", - "name": "[parameters('KeyVaultName')]", - "location": "[parameters('kvRegion')]", - "properties": { - "sku": { - "family": "A", - "name": "standard" - }, - "tenantId": "[subscription().tenantId]", - "accessPolicies": [] - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/logAnalyticsWorkspace.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/logAnalyticsWorkspace.json deleted file mode 100644 index c3cca157..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/logAnalyticsWorkspace.json +++ /dev/null @@ -1,69 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "rgName": { - "type": "String" - }, - "workspaceName": { - "type": "String", - "defaultValue": "" - }, - "workspaceRegion": { - "type": "String", - "defaultValue": "[deployment().location]" - }, - "retentionInDays": { - "type": "String", - "defaultValue": "30" - } - }, - "variables": { - "laDeploymentName": "fsi-loganalytics" - }, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2018-05-01", - "name": "[parameters('rgName')]", - "location": "[deployment().location]", - "properties": {} - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2018-05-01", - "name": "[variables('laDeploymentName')]", - "resourceGroup": "[parameters('rgName')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/resourceGroups/', parameters('rgName'))]" - ], - "properties": { - "mode": "Incremental", - "template": { - "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "apiVersion": "2020-08-01", - "location": "[parameters('workspaceRegion')]", - "name": "[parameters('workspaceName')]", - "type": "Microsoft.OperationalInsights/workspaces", - "properties": { - "sku": { - "name": "PerGB2018" - }, - "enableLogAccessUsingOnlyResourcePermissions": true, - "retentionInDays": "[int(parameters('retentionInDays'))]" - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringHubSpoke.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringHubSpoke.json deleted file mode 100644 index 77863859..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringHubSpoke.json +++ /dev/null @@ -1,63 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "hubVnetName": { - "type": "string", - "metadata": { - "description": "The name of the Hub Virtual Network." - } - }, - "spokeVnetResourceId": { - "type": "string", - "metadata": { - "description": "Resource ID of the Spoke Virtual Network." - } - }, - "hubVnetResourceGroup":{ - "type": "string" - }, - "peeringRegion": { - "type": "string", - "defaultValue":"[deployment().location]" - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2019-10-01", - "name": "hubToSpokePeering", - "resourceGroup": "[parameters('hubVnetResourceGroup')]", - - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks/virtualNetworkPeerings", - "apiVersion": "2020-06-01", - "name": "[concat(parameters('hubVnetName'), '/AddPeeringToSpoke')]", - "properties": { - "allowVirtualNetworkAccess": true, - "allowForwardedTraffic": false, - "allowGatewayTransit": false, - "useRemoteGateways": false, - "remoteVirtualNetwork": { - "id": "[parameters('spokeVnetResourceId')]" - } - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringSpokeHub.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringSpokeHub.json deleted file mode 100644 index a292563d..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/peeringSpokeHub.json +++ /dev/null @@ -1,63 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "spokeVnetName": { - "type": "string", - "metadata": { - "description": "Name of the Spoke Virtual Network." - } - }, - "hubVnetResourceId": { - "type": "string", - "metadata": { - "description": "Resource ID of the Hub Virtual Network." - } - }, - "spokeVnetResourceGroup":{ - "type": "string" - }, - "peeringRegion": { - "type": "string", - "defaultValue":"[deployment().location]" - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2019-10-01", - "name": "spokeToHubPeering", - "resourceGroup": "[parameters('spokeVnetResourceGroup')]", - - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks/virtualNetworkPeerings", - "apiVersion": "2020-06-01", - "name": "[concat(parameters('spokeVnetName'), '/AddPeeringToHub')]", - "properties": { - "allowVirtualNetworkAccess": true, - "allowForwardedTraffic": false, - "allowGatewayTransit": false, - "useRemoteGateways": false, - "remoteVirtualNetwork": { - "id": "[parameters('hubVnetResourceId')]" - } - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/recoveryServicesVault.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/recoveryServicesVault.json deleted file mode 100644 index eeac6492..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/recoveryServicesVault.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "recoveryName": { - "type": "string", - "metadata": { - "description": "The name of the Recovery Services Vault" - } - }, - "rgName": { - "type": "string", - "metadata": { - "description": "The name of the resource group in which to deploy the Recovery Services Vault" - } - }, - "rsvRegion": { - "type": "string", - "defaultValue": "[deployment().location]" - } - - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2018-05-01", - "name": "[parameters('rgName')]", - "location": "[deployment().location]", - "properties": {} - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2018-05-01", - "name": "[concat(parameters('recoveryName'),'-rsv')]", - "resourceGroup": "[parameters('rgName')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/resourceGroups/', parameters('rgName'))]" - ], - "properties": { - "mode": "Incremental", - "template": { - "$schema": "http://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.RecoveryServices/vaults", - "apiVersion": "2021-08-01", - "name": "[parameters('recoveryName')]", - "location": "[parameters('rsvRegion')]", - "properties": {}, - "sku": { - "name": "Standard", - "tier": "Standard" - }, - "tags": {} - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/spokeNetwork.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/spokeNetwork.json deleted file mode 100644 index ec79a63f..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/spokeNetwork.json +++ /dev/null @@ -1,91 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "rgName": { - "type": "String" - }, - "spokeName": { - "type": "string", - "metadata": { - "description": "Name of the Virtual Network" - } - }, - "spokeAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Virtual Network" - } - }, - "spokeRegion": { - "type": "String", - "defaultValue": "[deployment().location]" - }, - "spokeSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the Hub Subnet" - } - }, - "spokeSubnetAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Hub Subnet" - } - } - }, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2018-05-01", - "name": "[parameters('rgName')]", - "location": "[deployment().location]", - "properties": {} - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2018-05-01", - "name": "hub-network", - "resourceGroup": "[parameters('rgName')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/resourceGroups/', parameters('rgName'))]" - ], - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworks", - "apiVersion": "2021-02-01", - "name": "[parameters('spokeName')]", - "location": "[parameters('spokeRegion')]", - "properties": { - "addressSpace": { - "addressPrefixes": [ - "[parameters('spokeAddrPrefix')]" - ] - }, - "subnets": [ - { - "name": "[parameters('spokeSubnetName')]", - "properties": { - "addressPrefix": "[parameters('spokeSubnetAddrPrefix')]" - } - } - ] - } - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/vpnGateway.json b/AzureLandingZoneforNonprofits/core/subscriptionTemplates/vpnGateway.json deleted file mode 100644 index 960d5b5c..00000000 --- a/AzureLandingZoneforNonprofits/core/subscriptionTemplates/vpnGateway.json +++ /dev/null @@ -1,113 +0,0 @@ - -{ - "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "rgName": { - "type": "string" - }, - "hubName": { - "type": "string", - "metadata": { - "description": "Name of the Virtual Network" - } - }, - "vpnGWRegion": { - "type": "string", - "defaultValue": "[deployment().location]" - }, - "gatewaySubnetName": { - "type": "string", - "defaultValue": "GatewaySubnet", - "metadata": { - "description": "Name of the Gateway Subnet within the Virtual Network" - } - }, - "newPublicIpAddressName": { - "type": "string", - "defaultValue": "vpn-gw-imc-pip-01" - }, - "subscriptionId": { - "type": "string", - "defaultValue": "" - }, - "resourceGroupName":{ - "type": "string" - } - }, - "variables": { - "azVpnGwSubnetId": "[concat('/subscriptions/', parameters('subscriptionId'), '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Network/virtualNetworks/', parameters('hubName'), '/subnets/GatewaySubnet')]", - "azVpnGwPipId": "[concat('/subscriptions/', parameters('subscriptionId'), '/resourceGroups/', parameters('resourceGroupName'), '/providers/Microsoft.Network/publicIPAddresses/', parameters('newPublicIpAddressName'))]" - }, - "resources": [ - { - "type": "Microsoft.Resources/resourceGroups", - "apiVersion": "2018-05-01", - "name": "[parameters('rgName')]", - "location": "[deployment().location]", - "properties": {} - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2018-05-01", - "name": "hub-network", - "resourceGroup": "[parameters('rgName')]", - "properties": { - "mode": "Incremental", - "template": { - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": {}, - "variables": {}, - "resources": [ - { - "type": "Microsoft.Network/virtualNetworkGateways", - "apiVersion": "2021-02-01", - "name": "vpn-gw-imc", - "location": "[parameters('vpnGWRegion')]", - "properties": { - "gatewayType": "Vpn", - "vpnType": "RouteBased", - "sku": { - "name": "VpnGw1", - "tier": "VpnGw1" - }, - "vpnGatewayGeneration": "Generation1", - "ipConfigurations": [ - { - "name": "default", - "properties": { - "privateIPAllocationMethod": "Dynamic", - "subnet": { - "id": "[variables('azVpnGwSubnetId')]" - }, - "publicIpAddress": { - "id": "[variables('azVpnGwPipId')]" - } - } - } - ] - } - }, - { - "apiVersion": "2020-08-01", - "type": "Microsoft.Network/publicIPAddresses", - "name": "[parameters('newPublicIpAddressName')]", - "location": "[deployment().location]", - "properties": { - "publicIPAllocationMethod": "Static" - }, - "sku": { - "name": "Standard", - "tier": "Regional" - }, - "zones": [] - } - ], - "outputs": {} - } - } - } - ], - "outputs": {} -} diff --git a/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json b/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json new file mode 100644 index 00000000..9d971321 --- /dev/null +++ b/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json @@ -0,0 +1,19 @@ +{ + "scenario": "expanded-platform", + "deploymentName": "alz-expanded-dev", + "deploymentLocation": "eastus", + "parametersFile": "../parameters/expanded-platform/expanded-platform.parameters.json", + "validationLevel": "Provider", + "outputFolder": "artifacts/generated/cli/expanded-platform", + "nonInteractive": false, + "autoApprove": false, + "parameterOverrides": { + "deploymentPrefix": "alznp", + "primaryLocation": "eastus", + "allowedLocations": [ + "eastus" + ], + "managementSubscriptionId": "", + "connectivitySubscriptionId": "" + } +} \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json b/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json new file mode 100644 index 00000000..19c11ef7 --- /dev/null +++ b/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json @@ -0,0 +1,17 @@ +{ + "scenario": "foundation", + "deploymentName": "alz-foundation-dev", + "deploymentLocation": "eastus", + "parametersFile": "../parameters/foundation/foundation.subscription-only.parameters.json", + "scope": { + "subscription": "" + }, + "validationLevel": "Provider", + "outputFolder": "artifacts/generated/cli/foundation", + "nonInteractive": false, + "autoApprove": false, + "parameterOverrides": { + "deploymentPrefix": "alznp", + "primaryLocation": "eastus" + } +} \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/examples/parameters/README.md b/AzureLandingZoneforNonprofits/examples/parameters/README.md new file mode 100644 index 00000000..765ee70b --- /dev/null +++ b/AzureLandingZoneforNonprofits/examples/parameters/README.md @@ -0,0 +1,5 @@ +# Scenario Parameter Files + +This folder contains the canonical safe parameter files for the supported direct deployment scenarios. + +Budget deployment is opt-in. It runs whenever `monthlyBudgetAmount > 0` and `budgetContactEmails` is non-empty; the default `0` value skips the step. The `monthlyBudgetAmount` value is interpreted in the target budget subscription's billing currency, depending on billing setup. Foundation creates this budget in the Foundation subscription. Expanded Platform creates this budget in the management subscription. The deployment will fail fast on the budget step if the deployment identity does not have `Microsoft.Consumption/budgets/write` at the target budget subscription scope; keep `monthlyBudgetAmount` at `0` to skip the step. Newly created subscriptions may need up to 48 hours before Cost Management budget creation is available. diff --git a/AzureLandingZoneforNonprofits/examples/parameters/expanded-platform/expanded-platform.parameters.json b/AzureLandingZoneforNonprofits/examples/parameters/expanded-platform/expanded-platform.parameters.json new file mode 100644 index 00000000..3e616bca --- /dev/null +++ b/AzureLandingZoneforNonprofits/examples/parameters/expanded-platform/expanded-platform.parameters.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "deploymentPrefix": { + "value": "npalz" + }, + "primaryLocation": { + "value": "westeurope" + }, + "managementSubscriptionId": { + "value": "11111111-1111-1111-1111-111111111111" + }, + "connectivitySubscriptionId": { + "value": "22222222-2222-2222-2222-222222222222" + }, + "tags": { + "value": { + "Workload": "platform" + } + }, + "serviceOwner": { + "value": "platform@example.org" + }, + "monthlyBudgetAmount": { + "value": 0 + }, + "budgetContactEmails": { + "value": [] + }, + "allowedLocations": { + "value": [ + "westeurope" + ] + }, + "monitoringNotificationEmails": { + "value": [ + "ops@example.org" + ] + }, + "customerPlatformAdminsGroupObjectId": { + "value": "" + }, + "partnerOperatorsGroupObjectId": { + "value": "" + }, + "platformManagementGroupId": { + "value": "" + }, + "reserveGatewaySubnet": { + "value": false + }, + "enablePrivateDnsAndEndpoints": { + "value": false + }, + "enableKeyVaultPurgeProtection": { + "value": true + } + } +} diff --git a/AzureLandingZoneforNonprofits/examples/parameters/foundation/foundation.subscription-only.parameters.json b/AzureLandingZoneforNonprofits/examples/parameters/foundation/foundation.subscription-only.parameters.json new file mode 100644 index 00000000..690594d6 --- /dev/null +++ b/AzureLandingZoneforNonprofits/examples/parameters/foundation/foundation.subscription-only.parameters.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "deploymentPrefix": { + "value": "npalz" + }, + "primaryLocation": { + "value": "westeurope" + }, + "tags": { + "value": { + "Workload": "platform" + } + }, + "serviceOwner": { + "value": "platform@example.org" + }, + "monthlyBudgetAmount": { + "value": 0 + }, + "budgetContactEmails": { + "value": [] + }, + "monitoringNotificationEmails": { + "value": [ + "ops@example.org" + ] + }, + "customerPlatformAdminsGroupObjectId": { + "value": "" + }, + "partnerOperatorsGroupObjectId": { + "value": "" + }, + "enableSimpleNetwork": { + "value": false + }, + "enablePrivateDnsAndEndpoints": { + "value": false + } + } +} diff --git a/AzureLandingZoneforNonprofits/infra/entrypoints/direct/README.md b/AzureLandingZoneforNonprofits/infra/entrypoints/direct/README.md new file mode 100644 index 00000000..6ec3b1f4 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/entrypoints/direct/README.md @@ -0,0 +1,12 @@ +# Direct Entry Points + +This folder contains the direct Bicep entry points that define the supported deployment contract for implementation. + +Current initial scenarios: + +- `foundation-subscription.bicep` +- `expanded-platform.bicep` + +These entry points are shared deployment logic for CLI, automation, validation, and portal-safe wrappers. They stay under `infra/` because they are not owned solely by the CLI surface; `cli/` owns the installer and CLI example inputs. + +Foundation is subscription-only in the supported product contract. Expanded Platform always applies governance directly to its management and connectivity subscriptions, and can add a management-group assignment when an existing Platform management group is supplied through `platformManagementGroupId`. diff --git a/AzureLandingZoneforNonprofits/infra/entrypoints/direct/expanded-platform.bicep b/AzureLandingZoneforNonprofits/infra/entrypoints/direct/expanded-platform.bicep new file mode 100644 index 00000000..a6e3632c --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/entrypoints/direct/expanded-platform.bicep @@ -0,0 +1,315 @@ +targetScope = 'tenant' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the deployment.') +param primaryLocation string + +@description('Optional additional tags.') +param tags object = {} + +@description('Existing management subscription ID (GUID, 36 characters). Must already exist and be accessible to the deployment principal.') +@minLength(36) +@maxLength(36) +param managementSubscriptionId string + +@description('Existing connectivity subscription ID (GUID, 36 characters). Must already exist and be accessible to the deployment principal.') +@minLength(36) +@maxLength(36) +param connectivitySubscriptionId string + +@description('Service owner used for tags.') +param serviceOwner string + +@description('Monthly budget amount for the Expanded Platform management subscription, in the target subscription billing currency. Leave at 0 to skip budget creation. Creating a budget requires the deployment principal to hold Cost Management Contributor (or Contributor / Owner) on the management subscription, and the subscription must be at least ~48 hours old; without those prerequisites the deployment will fail on the budget step. Set this to 0 if unsure and create the budget manually after deployment.') +param monthlyBudgetAmount int = 0 + +@description('Budget notification email addresses. Required when monthlyBudgetAmount > 0; ignored when monthlyBudgetAmount = 0.') +param budgetContactEmails array = [] + +@description('Approved regional locations. Defaults to the primary location only.') +param allowedLocations array = [ + primaryLocation +] + +@description('Monitoring notification email addresses.') +param monitoringNotificationEmails array = [] + +@description('Defender for Cloud baseline. "recommended" enables paid Defender for Key Vault and Defender for Storage across the platform subscriptions managed by this deployment. "none" keeps existing paid Defender plan settings unchanged. The default is "none"; choose "recommended" only when recurring charges for Key Vault and Storage coverage are approved. Defender plans for App Service, SQL, Virtual Machines, and Kubernetes are not enabled by this deployment; enable them separately in Defender for Cloud when those workloads are deployed and recurring charges are approved.') +@allowed([ + 'recommended' + 'none' +]) +param defenderBaseline string = 'none' + +@description('Optional Microsoft Entra group object ID for the organization platform administrators.') +param customerPlatformAdminsGroupObjectId string = '' + +@description('Optional Microsoft Entra group object ID for the partner operators.') +param partnerOperatorsGroupObjectId string = '' + +@description('Optional management group ID for an additional Platform management group governance assignment. Expanded Platform still applies governance directly to the management and connectivity subscriptions.') +param platformManagementGroupId string = '' + +@description('Reserve the GatewaySubnet in the hub VNet so a VPN gateway, ExpressRoute gateway, or Azure Virtual WAN can be added later. This deployment reserves the subnet only; it does not create the gateway resource, public IP, or connection objects.') +param reserveGatewaySubnet bool = false + +@description('Optional private DNS and Key Vault private endpoints flag.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Enable purge protection on the Expanded Platform management Key Vault. Defaults to true for steady-state platform environments; set false for evaluation deployments that need immediate teardown.') +param enableKeyVaultPurgeProtection bool = true + +@description('Optional stable seed for the generated Expanded Platform management Key Vault name. Leave empty to preserve the default deterministic name; set a custom value only when an evaluation deployment must avoid a soft-deleted Key Vault name in the same resource group.') +param keyVaultNameSeed string = '' + +@description('Address space for the Expanded Platform hub VNet.') +param hubVnetAddressSpace string = '10.30.0.0/16' + +@description('Address prefix for the shared services subnet in the Expanded Platform hub VNet.') +param sharedServicesSubnetAddressPrefix string = '10.30.1.0/24' + +@description('Address prefix for the gateway subnet in the Expanded Platform hub VNet.') +param gatewaySubnetAddressPrefix string = '10.30.254.0/27' + +var usesSinglePlatformSubscription = toLower(managementSubscriptionId) == toLower(connectivitySubscriptionId) + +module managementSlice '../../modules/shared/subscription-platform-slice.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-management-platform' + scope: subscription(managementSubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + serviceOwner: serviceOwner + tags: tags + sliceName: 'management' + platformResourceGroupName: '${deploymentPrefix}-management-rg' + createWorkspace: true + createKeyVault: true + keyVaultNameSeed: keyVaultNameSeed + keyVaultPublicNetworkAccess: enablePrivateDnsAndEndpoints ? 'Disabled' : 'Enabled' + enableKeyVaultPurgeProtection: enableKeyVaultPurgeProtection + keyVaultSoftDeleteRetentionInDays: enableKeyVaultPurgeProtection ? 90 : 7 + } +} + +module connectivitySlice '../../modules/shared/subscription-platform-slice.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-platform' + scope: subscription(connectivitySubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + serviceOwner: serviceOwner + tags: tags + sliceName: 'connectivity' + platformResourceGroupName: '${deploymentPrefix}-connectivity-rg' + createWorkspace: false + createKeyVault: false + } +} + +module expandedNetworking './../../modules/networking/expanded-network-profile.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-network' + scope: subscription(connectivitySubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + tags: connectivitySlice.outputs.effectiveTags + networkResourceGroupName: connectivitySlice.outputs.platformResourceGroupName + keyVaultResourceId: managementSlice.outputs.keyVaultResourceId + reserveGatewaySubnet: reserveGatewaySubnet + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + hubVnetAddressSpace: hubVnetAddressSpace + sharedServicesSubnetAddressPrefix: sharedServicesSubnetAddressPrefix + gatewaySubnetAddressPrefix: gatewaySubnetAddressPrefix + } +} + +module managementMonitoring '../../modules/monitoring/subscription-monitoring-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-management-monitoring' + scope: subscription(managementSubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + platformResourceGroupName: managementSlice.outputs.platformResourceGroupName + logAnalyticsWorkspaceResourceId: managementSlice.outputs.logAnalyticsWorkspaceResourceId + monitoringNotificationEmails: monitoringNotificationEmails + keyVaultResourceId: managementSlice.outputs.keyVaultResourceId + tags: managementSlice.outputs.effectiveTags + } +} + +module managementSecurity '../../modules/security/subscription-security-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-management-security' + scope: subscription(managementSubscriptionId) + params: { + keyVaultResourceId: managementSlice.outputs.keyVaultResourceId + defenderBaseline: defenderBaseline + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + keyVaultPrivateEndpointImplemented: expandedNetworking.outputs.keyVaultPrivateEndpointImplemented + } +} + +module connectivityMonitoring '../../modules/monitoring/subscription-monitoring-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-monitoring' + scope: subscription(connectivitySubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + platformResourceGroupName: connectivitySlice.outputs.platformResourceGroupName + logAnalyticsWorkspaceResourceId: managementSlice.outputs.logAnalyticsWorkspaceResourceId + monitoringNotificationEmails: monitoringNotificationEmails + enableSubscriptionActivityLogDiagnostics: !usesSinglePlatformSubscription + enableActivityLogAlerts: !usesSinglePlatformSubscription + virtualNetworkResourceId: expandedNetworking.outputs.vnetResourceId + tags: connectivitySlice.outputs.effectiveTags + } +} + +module connectivitySecurity '../../modules/security/subscription-security-baseline.bicep' = if (!usesSinglePlatformSubscription) { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-security' + scope: subscription(connectivitySubscriptionId) + params: { + defenderBaseline: defenderBaseline + enablePrivateDnsAndEndpoints: false + } +} + +var aggregatedMonitoringDiagnosticOnboardingStatuses = [ + managementMonitoring.outputs.diagnosticOnboardingStatus + connectivityMonitoring.outputs.diagnosticOnboardingStatus +] +var governanceDiagnosticOnboardingStatus = contains(aggregatedMonitoringDiagnosticOnboardingStatuses, 'active-with-skipped-resource-types') ? 'active-with-skipped-resource-types' : contains(aggregatedMonitoringDiagnosticOnboardingStatuses, 'not-configured-no-workspace') ? 'not-configured-no-workspace' : 'active-via-monitoring-baseline' + +module platformManagementGroupGovernance '../../modules/governance/management-group-governance-baseline.bicep' = if (!empty(platformManagementGroupId)) { + name: 'nonprofit-alz-${deploymentPrefix}-platform-mg-governance' + scope: managementGroup(platformManagementGroupId) + params: { + deploymentPrefix: deploymentPrefix + allowedLocations: allowedLocations + serviceOwner: serviceOwner + primaryLocation: primaryLocation + logAnalyticsWorkspaceResourceId: managementSlice.outputs.logAnalyticsWorkspaceResourceId + diagnosticOnboardingStatus: governanceDiagnosticOnboardingStatus + } +} + +module managementSubscriptionGovernance '../../modules/governance/subscription-governance-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-management-governance' + scope: subscription(managementSubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + allowedLocations: allowedLocations + serviceOwner: serviceOwner + primaryLocation: primaryLocation + logAnalyticsWorkspaceResourceId: managementSlice.outputs.logAnalyticsWorkspaceResourceId + diagnosticOnboardingStatus: managementMonitoring.outputs.diagnosticOnboardingStatus + } +} + +module connectivitySubscriptionGovernance '../../modules/governance/subscription-governance-baseline.bicep' = if (!usesSinglePlatformSubscription) { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-governance' + scope: subscription(connectivitySubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + allowedLocations: allowedLocations + serviceOwner: serviceOwner + primaryLocation: primaryLocation + logAnalyticsWorkspaceResourceId: managementSlice.outputs.logAnalyticsWorkspaceResourceId + diagnosticOnboardingStatus: connectivityMonitoring.outputs.diagnosticOnboardingStatus + } +} + +module managementBudget '../../modules/governance/foundation-budget.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-management-budget' + scope: subscription(managementSubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + monthlyBudgetAmount: monthlyBudgetAmount + budgetContactEmails: budgetContactEmails + budgetNameSuffix: 'management' + budgetScopeLabel: 'Expanded Platform management subscription' + } +} + +module managementIdentity './../../modules/identity/subscription-access-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-management-access' + scope: subscription(managementSubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + customerPlatformAdminsGroupObjectId: customerPlatformAdminsGroupObjectId + partnerOperatorsGroupObjectId: partnerOperatorsGroupObjectId + partnerContributorResourceGroupNames: usesSinglePlatformSubscription ? [ + managementSlice.outputs.platformResourceGroupName + connectivitySlice.outputs.platformResourceGroupName + ] : [ + managementSlice.outputs.platformResourceGroupName + ] + logAnalyticsWorkspaceResourceId: managementSlice.outputs.logAnalyticsWorkspaceResourceId + } +} + +module connectivityIdentity './../../modules/identity/subscription-access-baseline.bicep' = if (!usesSinglePlatformSubscription) { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-access' + scope: subscription(connectivitySubscriptionId) + params: { + deploymentPrefix: deploymentPrefix + customerPlatformAdminsGroupObjectId: customerPlatformAdminsGroupObjectId + partnerOperatorsGroupObjectId: partnerOperatorsGroupObjectId + partnerContributorResourceGroupNames: [ + connectivitySlice.outputs.platformResourceGroupName + ] + logAnalyticsWorkspaceResourceId: '' + } +} + +var networkingHighImpactWarnings = expandedNetworking.outputs.highImpactWarnings +var aggregatedSecurityFollowUpActions = union( + managementSecurity.outputs.securityFollowUpActions, + usesSinglePlatformSubscription ? [] : connectivitySecurity!.outputs.securityFollowUpActions +) +var identityFollowUpActions = concat( + empty(customerPlatformAdminsGroupObjectId) ? [ + 'Provide the organization platform administrators group and verify Owner access on the platform subscriptions.' + ] : [], + empty(partnerOperatorsGroupObjectId) ? [ + 'Provide the partner operators group before enabling delegated partner operations.' + ] : [] +) + +var followUpActions = concat( + identityFollowUpActions, + empty(monitoringNotificationEmails) ? [ + 'Configure monitoring notification routing before relying on alert response.' + ] : [], + !empty(managementBudget.outputs.budgetFollowUpAction) ? [ + managementBudget.outputs.budgetFollowUpAction + ] : [], + !enableKeyVaultPurgeProtection ? [ + 'Expanded Platform management Key Vault was deployed with purge protection disabled so evaluation deployments can be removed promptly. Enable purge protection before storing platform secrets that must survive accidental deletion.' + ] : [], + length(allowedLocations) == 0 ? [ + 'Provide at least one allowed location so governance policies can be assigned consistently.' + ] : [], + aggregatedSecurityFollowUpActions, + managementMonitoring.outputs.followUpActions, + connectivityMonitoring.outputs.followUpActions, + expandedNetworking.outputs.networkingFollowUpActions +) + +output deploymentMode string = 'expanded-platform' +output createdManagementGroupIds array = [] +output platformResourceGroupName string = managementSlice.outputs.platformResourceGroupName +output networkResourceGroupName string = expandedNetworking.outputs.networkResourceGroupName +output logAnalyticsWorkspaceResourceId string = managementSlice.outputs.logAnalyticsWorkspaceResourceId +output keyVaultResourceId string = managementSlice.outputs.keyVaultResourceId +output vnetResourceId string = expandedNetworking.outputs.vnetResourceId +output keyVaultPrivateEndpointResourceId string = expandedNetworking.outputs.keyVaultPrivateEndpointResourceId +output networkingHighImpactWarnings array = networkingHighImpactWarnings +output securityKeyVaultPrivateHardeningStatus string = managementSecurity.outputs.keyVaultPrivateHardeningStatus +output governanceBudgetStatus string = managementBudget.outputs.budgetStatus +output handoverReady bool = managementIdentity.outputs.customerOwnedAccessConfigured +output alertResponseReady bool = managementMonitoring.outputs.alertResponseReady && (usesSinglePlatformSubscription || connectivityMonitoring.outputs.alertResponseReady) +output followUpActions array = followUpActions diff --git a/AzureLandingZoneforNonprofits/infra/entrypoints/direct/foundation-subscription.bicep b/AzureLandingZoneforNonprofits/infra/entrypoints/direct/foundation-subscription.bicep new file mode 100644 index 00000000..38d0c456 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/entrypoints/direct/foundation-subscription.bicep @@ -0,0 +1,171 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the deployment.') +param primaryLocation string + +@description('Optional additional tags.') +param tags object = {} + +@description('Service owner used for tags.') +param serviceOwner string + +@description('Monthly budget amount for the Foundation subscription, in the target subscription billing currency. Leave at 0 to skip budget creation. Creating a budget requires the deployment principal to hold Cost Management Contributor (or Contributor / Owner) on the target subscription, and the subscription must be at least ~48 hours old; without those prerequisites the deployment will fail on the budget step. Set this to 0 if unsure and create the budget manually after deployment.') +param monthlyBudgetAmount int = 0 + +@description('Budget notification email addresses. Required when monthlyBudgetAmount > 0; ignored when monthlyBudgetAmount = 0.') +param budgetContactEmails array = [] + +@description('Monitoring notification email addresses.') +param monitoringNotificationEmails array = [] + +@description('Defender for Cloud baseline. "recommended" enables paid Defender for Key Vault and Defender for Storage. "none" keeps existing paid Defender plan settings unchanged. The default is "none"; choose "recommended" only when recurring charges for Key Vault and Storage coverage are approved. Defender plans for App Service, SQL, Virtual Machines, and Kubernetes are not enabled by this deployment; enable them separately in Defender for Cloud when those workloads are deployed and recurring charges are approved.') +@allowed([ + 'recommended' + 'none' +]) +param defenderBaseline string = 'none' + +@description('Optional Microsoft Entra group object ID for the organization platform administrators.') +param customerPlatformAdminsGroupObjectId string = '' + +@description('Optional Microsoft Entra group object ID for the partner operators.') +param partnerOperatorsGroupObjectId string = '' + +@description('Deploy the optional simple Foundation network baseline.') +param enableSimpleNetwork bool = false + +@description('Enable private DNS and a private endpoint for the shared platform Key Vault. In Foundation this requires the simple network baseline.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Enable purge protection on the Foundation platform Key Vault. Defaults to false so evaluation deployments stay reversible (purge protection is irrevocable for 7 days once turned on). Enable this once the environment will hold real platform secrets that must survive accidental deletion.') +param enableKeyVaultPurgeProtection bool = false + +@description('Optional stable seed for the generated Foundation platform Key Vault name. Leave empty to preserve the default deterministic name; set a custom value only when an evaluation deployment must avoid a soft-deleted Key Vault name in the same resource group.') +param keyVaultNameSeed string = '' + +module foundationInputValidation '../../modules/networking/validation/foundation-input-validation.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-validation' + scope: subscription() + params: { + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + enableSimpleNetwork: enableSimpleNetwork + } +} + +module foundationPlatformBaseline '../../modules/shared/foundation-platform-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-platform-baseline' + scope: subscription() + dependsOn: [ + foundationInputValidation + ] + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + serviceOwner: serviceOwner + tags: tags + enableSimpleNetwork: enableSimpleNetwork + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + enableKeyVaultPurgeProtection: enableKeyVaultPurgeProtection + keyVaultNameSeed: keyVaultNameSeed + } +} + +module foundationMonitoring '../../modules/monitoring/subscription-monitoring-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-monitoring' + scope: subscription() + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + platformResourceGroupName: foundationPlatformBaseline.outputs.platformResourceGroupName + logAnalyticsWorkspaceResourceId: foundationPlatformBaseline.outputs.logAnalyticsWorkspaceResourceId + monitoringNotificationEmails: monitoringNotificationEmails + keyVaultResourceId: foundationPlatformBaseline.outputs.keyVaultResourceId + virtualNetworkResourceId: foundationPlatformBaseline.outputs.vnetResourceId + tags: foundationPlatformBaseline.outputs.effectiveTags + } +} + +module foundationSecurity '../../modules/security/subscription-security-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-security' + scope: subscription() + params: { + keyVaultResourceId: foundationPlatformBaseline.outputs.keyVaultResourceId + defenderBaseline: defenderBaseline + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + keyVaultPrivateEndpointImplemented: foundationPlatformBaseline.outputs.privateKeyVaultConnectivityEnabled + } +} + +module foundationBudget '../../modules/governance/foundation-budget.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-budget' + scope: subscription() + params: { + deploymentPrefix: deploymentPrefix + monthlyBudgetAmount: monthlyBudgetAmount + budgetContactEmails: budgetContactEmails + } +} + +module foundationIdentity './../../modules/identity/subscription-access-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-access' + scope: subscription() + params: { + deploymentPrefix: deploymentPrefix + customerPlatformAdminsGroupObjectId: customerPlatformAdminsGroupObjectId + partnerOperatorsGroupObjectId: partnerOperatorsGroupObjectId + partnerContributorResourceGroupNames: concat( + [ + foundationPlatformBaseline.outputs.platformResourceGroupName + ], + !empty(foundationPlatformBaseline.outputs.networkResourceGroupName) ? [ + foundationPlatformBaseline.outputs.networkResourceGroupName + ] : [] + ) + logAnalyticsWorkspaceResourceId: foundationPlatformBaseline.outputs.logAnalyticsWorkspaceResourceId + } +} + +var identityFollowUpActions = concat( + empty(customerPlatformAdminsGroupObjectId) ? [ + 'Provide the organization platform administrators group and verify Owner access on the subscription.' + ] : [], + empty(partnerOperatorsGroupObjectId) ? [ + 'Provide the partner operators group before enabling delegated partner operations.' + ] : [] +) + +var followUpActions = concat( + identityFollowUpActions, + foundationPlatformBaseline.outputs.networkingFollowUpActions, + empty(monitoringNotificationEmails) ? [ + 'Configure monitoring notification routing before relying on alert response.' + ] : [], + !enableKeyVaultPurgeProtection ? [ + 'Platform Key Vault was deployed with purge protection disabled so evaluation deployments can be removed promptly. Before storing secrets that must survive accidental deletion, set enableKeyVaultPurgeProtection to true and re-deploy. Note that once turned on it cannot be turned off for the 7-day soft-delete retention window.' + ] : [], + foundationSecurity.outputs.securityFollowUpActions, + foundationMonitoring.outputs.followUpActions, + !empty(foundationBudget.outputs.budgetFollowUpAction) ? [ + foundationBudget.outputs.budgetFollowUpAction + ] : [] +) + +output deploymentMode string = 'foundation-subscription' +output createdManagementGroupIds array = [] +output platformResourceGroupName string = foundationPlatformBaseline.outputs.platformResourceGroupName +output networkResourceGroupName string = foundationPlatformBaseline.outputs.networkResourceGroupName +output logAnalyticsWorkspaceResourceId string = foundationPlatformBaseline.outputs.logAnalyticsWorkspaceResourceId +output keyVaultResourceId string = foundationPlatformBaseline.outputs.keyVaultResourceId +output vnetResourceId string = foundationPlatformBaseline.outputs.vnetResourceId +output keyVaultPrivateEndpointResourceId string = foundationPlatformBaseline.outputs.keyVaultPrivateEndpointResourceId +output securityKeyVaultPrivateHardeningStatus string = foundationSecurity.outputs.keyVaultPrivateHardeningStatus +output governanceBudgetStatus string = foundationBudget.outputs.budgetStatus +output upgradeStatus string = 'not-applicable' +output hubToExistingFoundationPeeringIds array = [] +output handoverReady bool = foundationIdentity.outputs.customerOwnedAccessConfigured +output alertResponseReady bool = foundationMonitoring.outputs.alertResponseReady +output followUpActions array = followUpActions diff --git a/AzureLandingZoneforNonprofits/infra/modules/governance/README.md b/AzureLandingZoneforNonprofits/infra/modules/governance/README.md new file mode 100644 index 00000000..876033c0 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/governance/README.md @@ -0,0 +1,15 @@ +# Governance Modules + +This folder contains the governance and cost baseline implementation for Azure Landing Zone V2. + +Current modules: + +- `subscription-governance-baseline.bicep`: creates the custom Foundation governance initiative and assigns it at subscription scope +- `management-group-governance-baseline.bicep`: creates the same initiative and assigns it at management-group scope +- `foundation-budget.bicep`: creates an opt-in subscription budget when `monthlyBudgetAmount > 0` (in the target subscription billing currency) and `budgetContactEmails` is non-empty. Foundation uses it for the Foundation subscription; Expanded Platform uses it for the management subscription. + +Implementation notes: + +- The governance initiative keeps the built-in resource-group allowed-locations and required-tag policies, and adds a small custom resource-location policy so supported Azure global resource types do not require fake extra regions or ad hoc exemptions. +- Diagnostic onboarding status is driven by the monitoring baseline so governance outputs can report whether monitoring is active or only partially active. +- Budget creation runs whenever a budget amount and contact emails are supplied. The amount is interpreted in the subscription billing currency. The deployment fails fast on the budget step when the deployment identity lacks `Microsoft.Consumption/budgets/write`; set `monthlyBudgetAmount` to `0` to opt out of the budget step explicitly. diff --git a/AzureLandingZoneforNonprofits/infra/modules/governance/foundation-budget.bicep b/AzureLandingZoneforNonprofits/infra/modules/governance/foundation-budget.bicep new file mode 100644 index 00000000..1eeff686 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/governance/foundation-budget.bicep @@ -0,0 +1,64 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Monthly budget amount for the Foundation subscription, in the subscription billing currency. Set to 0 to skip budget creation.') +param monthlyBudgetAmount int = 0 + +@description('Budget notification email addresses.') +param budgetContactEmails array = [] + +@description('Budget start date in YYYY-MM-01 format.') +param budgetStartDate string = utcNow('yyyy-MM-01') + +@description('Suffix used in the subscription budget name.') +@maxLength(43) +param budgetNameSuffix string = 'foundation' + +@description('Customer-readable label for the subscription where the budget is created.') +param budgetScopeLabel string = 'Foundation subscription' + +var budgetName = '${deploymentPrefix}-${budgetNameSuffix}-budget' +var shouldCreateBudget = monthlyBudgetAmount > 0 && !empty(budgetContactEmails) +var budgetSkippedReason = shouldCreateBudget ? '' : monthlyBudgetAmount <= 0 ? 'monthly-budget-amount-not-supplied' : 'budget-contact-emails-not-supplied' +var budgetFollowUpAction = shouldCreateBudget ? '' : budgetSkippedReason == 'monthly-budget-amount-not-supplied' ? 'Provide a positive monthlyBudgetAmount in the subscription billing currency to enable the ${budgetScopeLabel} budget baseline.' : 'Provide at least one budgetContactEmails value to enable the ${budgetScopeLabel} budget baseline.' +var budgetPermissionGuidance = shouldCreateBudget ? 'If this deployment fails on the ${budgetScopeLabel} budget resource, the deployment identity is missing Microsoft.Consumption/budgets/write at the ${budgetScopeLabel} scope. Grant Cost Management Contributor (or Contributor / Owner) at that scope and rerun, or set monthlyBudgetAmount to 0 to skip the budget step. Newly created subscriptions may need up to 48 hours before Cost Management budget creation is available.' : '' + +resource foundationBudget 'Microsoft.Consumption/budgets@2024-08-01' = if (shouldCreateBudget) { + name: budgetName + properties: { + amount: monthlyBudgetAmount + category: 'Cost' + timeGrain: 'Monthly' + timePeriod: { + startDate: budgetStartDate + } + notifications: { + Actual80Percent: { + enabled: true + operator: 'GreaterThan' + threshold: 80 + thresholdType: 'Actual' + contactEmails: budgetContactEmails + locale: 'en-us' + } + Actual100Percent: { + enabled: true + operator: 'GreaterThan' + threshold: 100 + thresholdType: 'Actual' + contactEmails: budgetContactEmails + locale: 'en-us' + } + } + } +} + +output budgetStatus string = shouldCreateBudget ? 'created' : 'skipped' +output budgetResourceId string = shouldCreateBudget ? foundationBudget.id : '' +output budgetName string = shouldCreateBudget ? foundationBudget.name : budgetName +output budgetSkippedReason string = budgetSkippedReason +output budgetFollowUpAction string = budgetFollowUpAction +output budgetPermissionGuidance string = budgetPermissionGuidance diff --git a/AzureLandingZoneforNonprofits/infra/modules/governance/management-group-governance-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/governance/management-group-governance-baseline.bicep new file mode 100644 index 00000000..5224afb6 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/governance/management-group-governance-baseline.bicep @@ -0,0 +1,119 @@ +targetScope = 'managementGroup' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Approved Azure regions for the landing zone baseline.') +param allowedLocations array + +@description('Required ServiceOwner tag value. Applied to platform resource groups and inherited to resources by policy if missing.') +param serviceOwner string + +@description('Primary Azure region used for the policy assignment system-assigned identity.') +param primaryLocation string + +@description('Optional Log Analytics workspace resource ID used by later diagnostic onboarding work.') +param logAnalyticsWorkspaceResourceId string = '' + +@description('Diagnostic onboarding state reported by the monitoring baseline.') +param diagnosticOnboardingStatus string = empty(logAnalyticsWorkspaceResourceId) ? 'not-configured-no-workspace' : 'active-via-monitoring-baseline' + +var initiativeName = '${deploymentPrefix}-foundation-governance' +var initiativeAssignmentName = '${deploymentPrefix}-gov' +var initiativeDisplayName = '${deploymentPrefix} Foundation governance' +var allowedLocationsResourcesDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c' +var allowedLocationsResourceGroupsDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/e765b5de-1225-4ba3-bd56-1ac6695af988' + +resource governanceInitiative 'Microsoft.Authorization/policySetDefinitions@2025-01-01' = { + name: initiativeName + properties: { + policyType: 'Custom' + displayName: initiativeDisplayName + description: 'Minimal Azure Landing Zone governance baseline at management-group scope: allowed locations only. ServiceOwner tag inheritance is assigned separately to resource groups created by this deployment so existing resource groups outside this deployment are not affected.' + metadata: { + category: 'Governance' + version: '2.1.0' + source: 'AzureLandingZone' + } + parameters: { + allowedLocations: { + type: 'Array' + metadata: { + displayName: 'Allowed locations' + description: 'Approved Azure regions for the landing zone baseline.' + strongType: 'location' + } + defaultValue: allowedLocations + } + } + policyDefinitions: [ + { + policyDefinitionReferenceId: 'allowedLocationsResources' + policyDefinitionId: allowedLocationsResourcesDefinitionId + parameters: { + listOfAllowedLocations: { + value: '[parameters(\'allowedLocations\')]' + } + } + } + { + policyDefinitionReferenceId: 'allowedLocationsResourceGroups' + policyDefinitionId: allowedLocationsResourceGroupsDefinitionId + parameters: { + listOfAllowedLocations: { + value: '[parameters(\'allowedLocations\')]' + } + effect: { + value: 'Deny' + } + } + } + ] + } +} + +resource governanceAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { + name: initiativeAssignmentName + location: primaryLocation + properties: { + displayName: '${initiativeDisplayName} assignment' + description: 'Assigns the Azure Landing Zone governance baseline at management-group scope. Tag inheritance is assigned separately to resource groups created by this deployment.' + policyDefinitionId: governanceInitiative.id + enforcementMode: 'Default' + parameters: { + allowedLocations: { + value: allowedLocations + } + } + metadata: { + assignedBy: 'AzureLandingZone' + diagnosticOnboardingStatus: diagnosticOnboardingStatus + logAnalyticsWorkspaceResourceId: empty(logAnalyticsWorkspaceResourceId) ? 'not-configured' : logAnalyticsWorkspaceResourceId + serviceOwnerRgTag: serviceOwner + } + nonComplianceMessages: [ + { + policyDefinitionReferenceId: 'allowedLocationsResources' + message: 'Deploy Azure Landing Zone baseline resources only to approved Azure regions.' + } + ] + } +} + +output assignmentScopes array = [ + managementGroup().id +] +output initiativeAssignmentIds array = [ + governanceAssignment.id +] +output initiativeDefinitionIds array = [ + governanceInitiative.id +] +output policyDefinitionIds array = [ + allowedLocationsResourcesDefinitionId + allowedLocationsResourceGroupsDefinitionId +] +output diagnosticOnboardingStatuses array = [ + diagnosticOnboardingStatus +] diff --git a/AzureLandingZoneforNonprofits/infra/modules/governance/resource-group-tag-inheritance.bicep b/AzureLandingZoneforNonprofits/infra/modules/governance/resource-group-tag-inheritance.bicep new file mode 100644 index 00000000..90987846 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/governance/resource-group-tag-inheritance.bicep @@ -0,0 +1,53 @@ +targetScope = 'resourceGroup' + +@description('Short deployment prefix used to name the policy assignment.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region used for the policy assignment system-assigned identity.') +param primaryLocation string + +@description('Name of the tag to inherit from the resource group to resources that are missing it. Defaults to ServiceOwner.') +@maxLength(39) +param tagName string = 'ServiceOwner' + +var inheritTagFromRgIfMissingDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcdc54f7b070' +var tagContributorRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4a9ae827-6dc8-4573-8ac7-8239d42aa03f') +var assignmentName = '${deploymentPrefix}-tag-inherit-${toLower(tagName)}' + +resource inheritTagAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { + name: assignmentName + location: primaryLocation + identity: { + type: 'SystemAssigned' + } + properties: { + displayName: '${deploymentPrefix} ${tagName} tag inheritance (resource group scope)' + description: 'Inherits the ${tagName} tag from this resource group to resources inside it that are missing the tag. Scoped per resource group so existing resource groups outside this deployment are not affected.' + policyDefinitionId: inheritTagFromRgIfMissingDefinitionId + enforcementMode: 'Default' + parameters: { + tagName: { + value: tagName + } + } + metadata: { + assignedBy: 'AzureLandingZone' + tagInheritanceScope: 'alz-managed-resource-group' + } + } +} + +resource tagContributorAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, inheritTagAssignment.id, 'TagContributor') + properties: { + roleDefinitionId: tagContributorRoleDefinitionId + principalId: inheritTagAssignment.identity.principalId + principalType: 'ServicePrincipal' + description: 'Allows the per-resource-group tag inheritance policy to add the ${tagName} tag to resources inside this resource group.' + } +} + +output assignmentId string = inheritTagAssignment.id +output assignmentScope string = resourceGroup().id +output tagName string = tagName diff --git a/AzureLandingZoneforNonprofits/infra/modules/governance/subscription-governance-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/governance/subscription-governance-baseline.bicep new file mode 100644 index 00000000..66082828 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/governance/subscription-governance-baseline.bicep @@ -0,0 +1,118 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Approved Azure regions for the landing zone baseline.') +param allowedLocations array + +@description('Required ServiceOwner tag value. Applied to platform resource groups and inherited to resources by policy if missing.') +param serviceOwner string + +@description('Primary Azure region used for the policy assignment system-assigned identity.') +param primaryLocation string + +@description('Optional Log Analytics workspace resource ID used by later diagnostic onboarding work.') +param logAnalyticsWorkspaceResourceId string = '' + +@description('Diagnostic onboarding state reported by the monitoring baseline.') +param diagnosticOnboardingStatus string = empty(logAnalyticsWorkspaceResourceId) ? 'not-configured-no-workspace' : 'active-via-monitoring-baseline' + +var initiativeName = '${deploymentPrefix}-foundation-governance' +var initiativeDisplayName = '${deploymentPrefix} Foundation governance' +var allowedLocationsResourcesDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c' +var allowedLocationsResourceGroupsDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/e765b5de-1225-4ba3-bd56-1ac6695af988' + +resource governanceInitiative 'Microsoft.Authorization/policySetDefinitions@2025-01-01' = { + name: initiativeName + properties: { + policyType: 'Custom' + displayName: initiativeDisplayName + description: 'Minimal Azure Landing Zone governance baseline at subscription scope: allowed locations only. ServiceOwner tag inheritance is assigned separately to resource groups created by this deployment so existing resource groups outside this deployment are not affected.' + metadata: { + category: 'Governance' + version: '2.1.0' + source: 'AzureLandingZone' + } + parameters: { + allowedLocations: { + type: 'Array' + metadata: { + displayName: 'Allowed locations' + description: 'Approved Azure regions for the landing zone baseline.' + strongType: 'location' + } + defaultValue: allowedLocations + } + } + policyDefinitions: [ + { + policyDefinitionReferenceId: 'allowedLocationsResources' + policyDefinitionId: allowedLocationsResourcesDefinitionId + parameters: { + listOfAllowedLocations: { + value: '[parameters(\'allowedLocations\')]' + } + } + } + { + policyDefinitionReferenceId: 'allowedLocationsResourceGroups' + policyDefinitionId: allowedLocationsResourceGroupsDefinitionId + parameters: { + listOfAllowedLocations: { + value: '[parameters(\'allowedLocations\')]' + } + effect: { + value: 'Deny' + } + } + } + ] + } +} + +resource governanceAssignment 'Microsoft.Authorization/policyAssignments@2025-01-01' = { + name: initiativeName + location: primaryLocation + properties: { + displayName: '${initiativeDisplayName} assignment' + description: 'Assigns the Azure Landing Zone governance baseline at subscription scope. Tag inheritance is assigned separately to resource groups created by this deployment.' + policyDefinitionId: governanceInitiative.id + enforcementMode: 'Default' + parameters: { + allowedLocations: { + value: allowedLocations + } + } + metadata: { + assignedBy: 'AzureLandingZone' + diagnosticOnboardingStatus: diagnosticOnboardingStatus + logAnalyticsWorkspaceResourceId: empty(logAnalyticsWorkspaceResourceId) ? 'not-configured' : logAnalyticsWorkspaceResourceId + serviceOwnerRgTag: serviceOwner + } + nonComplianceMessages: [ + { + policyDefinitionReferenceId: 'allowedLocationsResources' + message: 'Deploy Azure Landing Zone baseline resources only to approved Azure regions.' + } + ] + } +} + +output assignmentScopes array = [ + subscription().id +] +output initiativeAssignmentIds array = [ + governanceAssignment.id +] +output initiativeDefinitionIds array = [ + governanceInitiative.id +] +output policyDefinitionIds array = [ + allowedLocationsResourcesDefinitionId + allowedLocationsResourceGroupsDefinitionId +] +output diagnosticOnboardingStatuses array = [ + diagnosticOnboardingStatus +] diff --git a/AzureLandingZoneforNonprofits/infra/modules/identity/README.md b/AzureLandingZoneforNonprofits/infra/modules/identity/README.md new file mode 100644 index 00000000..550c79df --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/identity/README.md @@ -0,0 +1,14 @@ +# Identity Modules + +This folder contains the Azure Landing Zone V2 identity, access, and partner-operations baseline. + +Current modules: + +- `subscription-access-baseline.bicep`: assigns the supported built-in roles at subscription, resource-group, and shared Log Analytics workspace scope for one platform subscription + +Implementation notes: + +- Role assignments target Microsoft Entra groups only. +- The implementation uses built-in roles only: `Owner`, `Contributor`, `Reader`, and `Log Analytics Contributor`. +- Partner access stays scoped and removable. The baseline does not assign partner `Owner`, tenant-wide roles, or management-group write roles by default. +- Workload administrators remain out of shared platform scopes by default and receive only the supported Foundation network-reader assignment when that scope exists. \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/infra/modules/identity/resource-group-role-assignment.bicep b/AzureLandingZoneforNonprofits/infra/modules/identity/resource-group-role-assignment.bicep new file mode 100644 index 00000000..5396eaf1 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/identity/resource-group-role-assignment.bicep @@ -0,0 +1,33 @@ +targetScope = 'resourceGroup' + +@description('Microsoft Entra group object ID that receives the role assignment.') +param principalObjectId string + +@description('Logical principal group label used in outputs.') +param principalGroup string + +@description('Built-in Azure role definition GUID.') +param roleDefinitionIdGuid string + +@description('Built-in Azure role display name used in outputs.') +param roleName string + +var roleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionIdGuid) + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(resourceGroup().id, principalObjectId, roleDefinitionIdGuid) + properties: { + roleDefinitionId: roleDefinitionId + principalId: principalObjectId + principalType: 'Group' + } +} + +output roleAssignment object = { + subscriptionId: subscription().subscriptionId + principalGroup: principalGroup + roleName: roleName + roleDefinitionId: roleDefinitionId + scopeKind: 'resourceGroup' + scopeId: resourceGroup().id +} diff --git a/AzureLandingZoneforNonprofits/infra/modules/identity/subscription-access-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/identity/subscription-access-baseline.bicep new file mode 100644 index 00000000..945893a4 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/identity/subscription-access-baseline.bicep @@ -0,0 +1,68 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Optional Microsoft Entra group object ID for the organization platform administrators.') +param customerPlatformAdminsGroupObjectId string = '' + +@description('Optional Microsoft Entra group object ID for the partner operators.') +param partnerOperatorsGroupObjectId string = '' + +@description('Resource group names where partner operators should receive Contributor.') +param partnerContributorResourceGroupNames array = [] + +@description('Optional Log Analytics workspace resource ID where partner operators may receive Log Analytics Contributor.') +param logAnalyticsWorkspaceResourceId string = '' + +var ownerRoleDefinitionId = '8e3af657-a8ff-443c-a75c-2fe8c4bcb635' +var contributorRoleDefinitionId = 'b24988ac-6180-42a0-ab88-20f7382dd24c' +var logAnalyticsContributorRoleDefinitionId = '92aaf0da-9dab-42b6-94a3-d43ce8d16293' +var partnerContributorScopeNames = union(partnerContributorResourceGroupNames, []) +var workspaceResourceGroupName = !empty(logAnalyticsWorkspaceResourceId) ? split(logAnalyticsWorkspaceResourceId, '/')[4] : '' +var workspaceName = !empty(logAnalyticsWorkspaceResourceId) ? split(logAnalyticsWorkspaceResourceId, '/')[8] : '' + +resource customerPlatformAdminsOwnerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(customerPlatformAdminsGroupObjectId)) { + name: guid(subscription().id, customerPlatformAdminsGroupObjectId, ownerRoleDefinitionId) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', ownerRoleDefinitionId) + principalId: customerPlatformAdminsGroupObjectId + principalType: 'Group' + } +} + +module partnerContributorAssignments './resource-group-role-assignment.bicep' = [for resourceGroupName in partnerContributorScopeNames: if (!empty(partnerOperatorsGroupObjectId)) { + name: '${deploymentPrefix}-partner-contributor-${uniqueString(subscription().id, resourceGroupName)}' + scope: resourceGroup(resourceGroupName) + params: { + principalObjectId: partnerOperatorsGroupObjectId + principalGroup: 'partnerOperators' + roleDefinitionIdGuid: contributorRoleDefinitionId + roleName: 'Contributor' + } +}] + +module partnerWorkspaceAssignment './workspace-role-assignment.bicep' = if (!empty(partnerOperatorsGroupObjectId) && !empty(logAnalyticsWorkspaceResourceId)) { + name: '${deploymentPrefix}-partner-workspace-${uniqueString(subscription().id, logAnalyticsWorkspaceResourceId)}' + scope: resourceGroup(workspaceResourceGroupName) + params: { + workspaceName: workspaceName + principalObjectId: partnerOperatorsGroupObjectId + principalGroup: 'partnerOperators' + roleDefinitionIdGuid: logAnalyticsContributorRoleDefinitionId + roleName: 'Log Analytics Contributor' + } +} + +var identityFollowUpActions = concat( + empty(customerPlatformAdminsGroupObjectId) ? [ + 'Organization platform administrators group was not supplied. Owner role assignment on the subscription was skipped; configure organization admin access before relying on this environment for operations.' + ] : [], + empty(partnerOperatorsGroupObjectId) && !empty(partnerContributorScopeNames) ? [ + 'Partner operators group was not supplied. Contributor role assignments on the platform resource groups were skipped; assign them only when delegated partner operations are required.' + ] : [] +) + +output customerOwnedAccessConfigured bool = !empty(customerPlatformAdminsGroupObjectId) +output identityFollowUpActions array = identityFollowUpActions diff --git a/AzureLandingZoneforNonprofits/infra/modules/identity/workspace-role-assignment.bicep b/AzureLandingZoneforNonprofits/infra/modules/identity/workspace-role-assignment.bicep new file mode 100644 index 00000000..722fd26c --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/identity/workspace-role-assignment.bicep @@ -0,0 +1,41 @@ +targetScope = 'resourceGroup' + +@description('Log Analytics workspace name.') +param workspaceName string + +@description('Microsoft Entra group object ID that receives the role assignment.') +param principalObjectId string + +@description('Logical principal group label used in outputs.') +param principalGroup string + +@description('Built-in Azure role definition GUID.') +param roleDefinitionIdGuid string + +@description('Built-in Azure role display name used in outputs.') +param roleName string + +var roleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionIdGuid) + +resource sharedWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' existing = { + name: workspaceName +} + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(sharedWorkspace.id, principalObjectId, roleDefinitionIdGuid) + scope: sharedWorkspace + properties: { + roleDefinitionId: roleDefinitionId + principalId: principalObjectId + principalType: 'Group' + } +} + +output roleAssignment object = { + subscriptionId: subscription().subscriptionId + principalGroup: principalGroup + roleName: roleName + roleDefinitionId: roleDefinitionId + scopeKind: 'workspace' + scopeId: sharedWorkspace.id +} diff --git a/AzureLandingZoneforNonprofits/infra/modules/monitoring/README.md b/AzureLandingZoneforNonprofits/infra/modules/monitoring/README.md new file mode 100644 index 00000000..60266cf6 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/monitoring/README.md @@ -0,0 +1,19 @@ +# Monitoring Modules + +This folder contains the Azure Landing Zone V2 monitoring and operations baseline. + +Current modules: + +- `subscription-monitoring-baseline.bicep`: configures subscription Activity Log routing, resource diagnostics for currently supported landing-zone resources, and the minimal alert set for a single platform subscription +- `activity-log-alerts.bicep`: creates the action group plus Service Health and Planned Maintenance activity log alerts in the platform resource group +- `keyvault-diagnostics.bicep`: configures Key Vault diagnostics to the shared workspace +- `virtual-network-diagnostics.bicep`: configures Virtual Network diagnostics to the shared workspace + +The current monitoring orchestrator accepts explicit resource IDs for the resource types that are supported today. Direct entry points already know these IDs, so extending coverage later does not require changing the top-level deployment experience. + +Current supported diagnostic resource types: + +- `Microsoft.KeyVault/vaults` +- `Microsoft.Network/virtualNetworks` + +DDoS telemetry remains an advanced extension path in this version. The default monitoring baseline must not claim full DDoS diagnostic onboarding. diff --git a/AzureLandingZoneforNonprofits/infra/modules/monitoring/activity-log-alerts.bicep b/AzureLandingZoneforNonprofits/infra/modules/monitoring/activity-log-alerts.bicep new file mode 100644 index 00000000..66438fd8 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/monitoring/activity-log-alerts.bicep @@ -0,0 +1,108 @@ +targetScope = 'resourceGroup' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Monitoring notification email addresses.') +param monitoringNotificationEmails array + +@description('Optional common tags applied to monitoring resources that support tags.') +param tags object = {} + +var actionGroupName = '${deploymentPrefix}-mon-ag-001' +var actionGroupShortNameSeed = replace('${deploymentPrefix}ops', '-', '') +var actionGroupShortName = empty(actionGroupShortNameSeed) ? 'alzops' : take(actionGroupShortNameSeed, 12) +var serviceHealthAlertName = '${deploymentPrefix}-servicehealth-001' +var plannedMaintenanceAlertName = '${deploymentPrefix}-plannedmaint-001' + +resource monitoringActionGroup 'Microsoft.Insights/actionGroups@2023-01-01' = { + name: actionGroupName + location: 'global' + tags: tags + properties: { + enabled: true + groupShortName: actionGroupShortName + emailReceivers: [for (emailAddress, index) in monitoringNotificationEmails: { + name: 'email${index + 1}' + emailAddress: emailAddress + useCommonAlertSchema: true + }] + } +} + +resource serviceHealthAlert 'Microsoft.Insights/activityLogAlerts@2023-01-01-preview' = { + name: serviceHealthAlertName + location: 'global' + tags: tags + properties: { + enabled: true + scopes: [ + subscription().id + ] + condition: { + allOf: [ + { + field: 'category' + equals: 'ServiceHealth' + } + { + field: 'properties.incidentType' + equals: 'Incident' + } + ] + } + actions: { + actionGroups: [ + { + actionGroupId: monitoringActionGroup.id + } + ] + } + } +} + +resource plannedMaintenanceAlert 'Microsoft.Insights/activityLogAlerts@2023-01-01-preview' = { + name: plannedMaintenanceAlertName + location: 'global' + tags: tags + properties: { + enabled: true + scopes: [ + subscription().id + ] + condition: { + allOf: [ + { + field: 'category' + equals: 'ServiceHealth' + } + { + field: 'properties.incidentType' + equals: 'PlannedMaintenance' + } + ] + } + actions: { + actionGroups: [ + { + actionGroupId: monitoringActionGroup.id + } + ] + } + } +} + +output actionGroupResourceId string = monitoringActionGroup.id +output alertResources array = [ + { + subscriptionId: subscription().subscriptionId + alertType: 'serviceHealth' + resourceId: serviceHealthAlert.id + } + { + subscriptionId: subscription().subscriptionId + alertType: 'plannedMaintenance' + resourceId: plannedMaintenanceAlert.id + } +] diff --git a/AzureLandingZoneforNonprofits/infra/modules/monitoring/keyvault-diagnostics.bicep b/AzureLandingZoneforNonprofits/infra/modules/monitoring/keyvault-diagnostics.bicep new file mode 100644 index 00000000..679cf546 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/monitoring/keyvault-diagnostics.bicep @@ -0,0 +1,41 @@ +targetScope = 'resourceGroup' + +@description('Key Vault resource name.') +param keyVaultName string + +@description('Shared Log Analytics workspace resource ID.') +param logAnalyticsWorkspaceResourceId string + +@description('Diagnostic setting name.') +param diagnosticSettingName string = 'alz-kv-diag' + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName +} + +resource keyVaultDiagnosticSetting 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingName + scope: keyVault + properties: { + workspaceId: logAnalyticsWorkspaceResourceId + logAnalyticsDestinationType: 'Dedicated' + logs: [ + { + category: 'AuditEvent' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +output diagnosticSetting object = { + resourceType: 'Microsoft.KeyVault/vaults' + resourceId: keyVault.id + diagnosticSettingId: keyVaultDiagnosticSetting.id +} diff --git a/AzureLandingZoneforNonprofits/infra/modules/monitoring/subscription-monitoring-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/monitoring/subscription-monitoring-baseline.bicep new file mode 100644 index 00000000..7211f703 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/monitoring/subscription-monitoring-baseline.bicep @@ -0,0 +1,170 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the deployment.') +param primaryLocation string + +@description('Platform resource group name for monitoring resources such as the action group and activity log alerts.') +param platformResourceGroupName string + +@description('Shared Log Analytics workspace resource ID.') +param logAnalyticsWorkspaceResourceId string + +@description('Monitoring notification email addresses.') +param monitoringNotificationEmails array = [] + +@description('Create subscription Activity Log diagnostic settings. Disable only when another module in the same deployment already owns subscription Activity Log routing for this subscription.') +param enableSubscriptionActivityLogDiagnostics bool = true + +@description('Create subscription Activity Log alert resources. Disable only when another module in the same deployment already owns subscription alerting for this subscription.') +param enableActivityLogAlerts bool = true + +@description('Optional Key Vault resource ID that should route diagnostics to the shared workspace.') +param keyVaultResourceId string = '' + +@description('Optional virtual network resource ID that should route diagnostics to the shared workspace.') +param virtualNetworkResourceId string = '' + +@description('Optional common tags applied to monitoring resources that support tags.') +param tags object = {} + +var activityLogDiagnosticSettingName = '${deploymentPrefix}-activitylog' +var activityLogCategories = [ + 'Administrative' + 'Policy' + 'Security' + 'ServiceHealth' + 'Alert' + 'Recommendation' +] +var workspaceConfigured = !empty(logAnalyticsWorkspaceResourceId) +var subscriptionActivityLogDiagnosticsEnabled = workspaceConfigured && enableSubscriptionActivityLogDiagnostics +var activityLogAlertsEnabled = !empty(monitoringNotificationEmails) && enableActivityLogAlerts +var skippedMonitoringActions = concat( + !workspaceConfigured ? [ + { + subscriptionId: subscription().subscriptionId + action: 'workspace-routing' + reason: 'log-analytics-workspace-resource-id-not-supplied' + } + ] : [], + workspaceConfigured && !enableSubscriptionActivityLogDiagnostics ? [ + { + subscriptionId: subscription().subscriptionId + action: 'subscription-activity-log-diagnostics' + reason: 'subscription-activity-log-diagnostics-disabled' + } + ] : [], + empty(monitoringNotificationEmails) && enableActivityLogAlerts ? [ + { + subscriptionId: subscription().subscriptionId + action: 'activity-log-alerts' + reason: 'monitoring-notification-emails-not-supplied' + } + ] : [], + !enableActivityLogAlerts ? [ + { + subscriptionId: subscription().subscriptionId + action: 'activity-log-alerts' + reason: 'activity-log-alerts-disabled' + } + ] : [] +) +var followUpActions = concat( + !workspaceConfigured ? [ + 'Monitoring diagnostics were skipped because logAnalyticsWorkspaceResourceId was not provided. Supply the shared workspace before declaring diagnostic onboarding complete.' + ] : [], + empty(monitoringNotificationEmails) && enableActivityLogAlerts ? [ + 'Monitoring alerts were skipped because monitoringNotificationEmails was not provided. Add at least one recipient before declaring alert-response readiness.' + ] : [] +) +var diagnosticOnboardingStatus = workspaceConfigured ? 'active-via-monitoring-baseline' : 'not-configured-no-workspace' + +resource platformResourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' existing = { + name: platformResourceGroupName +} + +// API version note (applies to every Microsoft.Insights/diagnosticSettings resource in this +// solution: this module plus keyvault-diagnostics and virtual-network-diagnostics): +// 2021-05-01-preview is intentionally used. The only non-preview diagnosticSettings API versions +// (2015-07-01 and 2016-09-01) do not support logAnalyticsDestinationType: 'Dedicated' and use an +// older logs/metrics shape. 2021-05-01-preview is the de facto production version used by +// Azure Verified Modules, the CAF/ALZ accelerator, and current Microsoft Learn quickstarts, so +// we keep it across all diagnosticSettings resources here. +resource activityLogDiagnosticSetting 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (subscriptionActivityLogDiagnosticsEnabled) { + name: activityLogDiagnosticSettingName + properties: { + workspaceId: logAnalyticsWorkspaceResourceId + logAnalyticsDestinationType: 'Dedicated' + logs: [for category in activityLogCategories: { + category: category + enabled: true + }] + } +} + +module activityLogAlerts './activity-log-alerts.bicep' = if (activityLogAlertsEnabled) { + name: '${deploymentPrefix}-activity-alerts' + scope: platformResourceGroup + params: { + deploymentPrefix: deploymentPrefix + monitoringNotificationEmails: monitoringNotificationEmails + tags: union(tags, { + PrimaryLocation: primaryLocation + }) + } +} + +module keyVaultDiagnostics './keyvault-diagnostics.bicep' = if (workspaceConfigured && !empty(keyVaultResourceId)) { + name: '${deploymentPrefix}-keyvault-monitoring-${uniqueString(keyVaultResourceId)}' + scope: resourceGroup(split(keyVaultResourceId, '/')[4]) + params: { + keyVaultName: split(keyVaultResourceId, '/')[8] + logAnalyticsWorkspaceResourceId: logAnalyticsWorkspaceResourceId + diagnosticSettingName: '${deploymentPrefix}-kv-diag' + } +} + +module virtualNetworkDiagnostics './virtual-network-diagnostics.bicep' = if (workspaceConfigured && !empty(virtualNetworkResourceId)) { + name: '${deploymentPrefix}-vnet-monitoring-${uniqueString(virtualNetworkResourceId)}' + scope: resourceGroup(split(virtualNetworkResourceId, '/')[4]) + params: { + virtualNetworkName: split(virtualNetworkResourceId, '/')[8] + logAnalyticsWorkspaceResourceId: logAnalyticsWorkspaceResourceId + diagnosticSettingName: '${deploymentPrefix}-vnet-diag' + } +} + +output sharedLogAnalyticsWorkspaceResourceId string = logAnalyticsWorkspaceResourceId +output diagnosticOnboardingStatus string = diagnosticOnboardingStatus +output alertResponseReady bool = activityLogAlertsEnabled +output activityLogDiagnosticSettings array = subscriptionActivityLogDiagnosticsEnabled ? [ + { + subscriptionId: subscription().subscriptionId + diagnosticSettingId: activityLogDiagnosticSetting.id + } +] : [] +output alertResources array = activityLogAlertsEnabled ? activityLogAlerts!.outputs.alertResources : [] +output resourceDiagnosticSettings array = concat( + workspaceConfigured && !empty(keyVaultResourceId) ? [ + { + subscriptionId: subscription().subscriptionId + resourceType: keyVaultDiagnostics!.outputs.diagnosticSetting.resourceType + resourceId: keyVaultDiagnostics!.outputs.diagnosticSetting.resourceId + diagnosticSettingId: keyVaultDiagnostics!.outputs.diagnosticSetting.diagnosticSettingId + } + ] : [], + workspaceConfigured && !empty(virtualNetworkResourceId) ? [ + { + subscriptionId: subscription().subscriptionId + resourceType: virtualNetworkDiagnostics!.outputs.diagnosticSetting.resourceType + resourceId: virtualNetworkDiagnostics!.outputs.diagnosticSetting.resourceId + diagnosticSettingId: virtualNetworkDiagnostics!.outputs.diagnosticSetting.diagnosticSettingId + } + ] : [] +) +output skippedMonitoringActions array = skippedMonitoringActions +output followUpActions array = followUpActions diff --git a/AzureLandingZoneforNonprofits/infra/modules/monitoring/virtual-network-diagnostics.bicep b/AzureLandingZoneforNonprofits/infra/modules/monitoring/virtual-network-diagnostics.bicep new file mode 100644 index 00000000..4a86caeb --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/monitoring/virtual-network-diagnostics.bicep @@ -0,0 +1,41 @@ +targetScope = 'resourceGroup' + +@description('Virtual network resource name.') +param virtualNetworkName string + +@description('Shared Log Analytics workspace resource ID.') +param logAnalyticsWorkspaceResourceId string + +@description('Diagnostic setting name.') +param diagnosticSettingName string = 'alz-vnet-diag' + +resource virtualNetwork 'Microsoft.Network/virtualNetworks@2024-05-01' existing = { + name: virtualNetworkName +} + +resource virtualNetworkDiagnosticSetting 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = { + name: diagnosticSettingName + scope: virtualNetwork + properties: { + workspaceId: logAnalyticsWorkspaceResourceId + logAnalyticsDestinationType: 'Dedicated' + logs: [ + { + categoryGroup: 'allLogs' + enabled: true + } + ] + metrics: [ + { + category: 'AllMetrics' + enabled: true + } + ] + } +} + +output diagnosticSetting object = { + resourceType: 'Microsoft.Network/virtualNetworks' + resourceId: virtualNetwork.id + diagnosticSettingId: virtualNetworkDiagnosticSetting.id +} diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/README.md b/AzureLandingZoneforNonprofits/infra/modules/networking/README.md new file mode 100644 index 00000000..12f9174a --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/README.md @@ -0,0 +1,20 @@ +# Networking Modules + +This folder contains the bounded networking profiles for Azure Landing Zone V2. + +Current modules: + +- `foundation-network-profile.bicep`: orchestrates the optional Foundation VNet, subnet layout, and Key Vault private connectivity path +- `foundation-network-resources.bicep`: creates the Foundation VNet, subnets, private DNS zone, virtual network link, and Key Vault private endpoint resources in the Foundation network resource group +- `expanded-network-profile.bicep`: orchestrates the bounded Expanded Platform hub network and optional advanced networking features +- `expanded-hub-network-resources.bicep`: creates the Expanded Platform hub VNet, optional GatewaySubnet reservation, and private Key Vault connectivity + +Implementation notes: + +- Foundation networking is disabled by default. +- Foundation networking never creates hub-and-spoke resources. +- Expanded Platform always creates one dedicated hub VNet in the connectivity subscription. +- Optional Expanded Platform networking features are bounded to GatewaySubnet reservation and Key Vault private connectivity. +- The deployment does not create peering between Foundation and Expanded Platform. Organizations that want to peer an existing Foundation VNet to a new Expanded hub can follow the manual peering runbook (`docs/runbooks/foundation-to-expanded-peering.md`). +- Private connectivity is limited to the shared platform Key Vault in this implementation. +- In Foundation, private Key Vault connectivity requires the simple network baseline. \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/expanded-hub-network-resources.bicep b/AzureLandingZoneforNonprofits/infra/modules/networking/expanded-hub-network-resources.bicep new file mode 100644 index 00000000..0ebb07f5 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/expanded-hub-network-resources.bicep @@ -0,0 +1,142 @@ +targetScope = 'resourceGroup' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the Expanded Platform hub resources.') +param primaryLocation string + +@description('Optional common tags applied to Expanded Platform networking resources.') +param tags object = {} + +@description('Shared platform Key Vault resource ID used for the optional private endpoint path.') +param keyVaultResourceId string = '' + +// NOTE: reserveGatewaySubnet only carves out the GatewaySubnet inside the hub VNet so a +// VPN gateway, ExpressRoute gateway, or Azure Virtual WAN can be added later without +// re-architecting the hub address space. This module deliberately does NOT deploy the +// gateway resource itself, the public IP, or any IPsec/connection objects. Hybrid +// connectivity sizing, SKU choice (VpnGw1/VpnGw1AZ/VWAN), on-premises device +// configuration, and ongoing operations remain separate design decisions. +@description('Reserve the GatewaySubnet in the hub VNet so a VPN gateway, ExpressRoute gateway, or Azure Virtual WAN can be added later. This deployment reserves the subnet only; it does not create the gateway resource, public IP, or connection objects.') +param reserveGatewaySubnet bool = false + +@description('Enable private DNS and a private endpoint for the shared platform Key Vault.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Address space for the Expanded Platform hub VNet.') +param hubVnetAddressSpace string = '10.30.0.0/16' + +@description('Address prefix for the shared services subnet in the hub VNet.') +param sharedServicesSubnetAddressPrefix string = '10.30.1.0/24' + +@description('Address prefix for the gateway subnet in the hub VNet.') +param gatewaySubnetAddressPrefix string = '10.30.254.0/27' + +var hubVnetName = '${deploymentPrefix}-vnet-001' +var sharedServicesSubnetName = 'shared-services' +var gatewaySubnetName = 'GatewaySubnet' +var keyVaultPrivateDnsZoneName = 'privatelink.vaultcore.azure.net' +var keyVaultPrivateEndpointName = '${deploymentPrefix}-kv-pe-001' +var keyVaultPrivateLinkConnectionName = '${deploymentPrefix}-kv-pls-001' + +resource hubVnet 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: hubVnetName + location: primaryLocation + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ + hubVnetAddressSpace + ] + } + } +} + +resource sharedServicesSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: hubVnet + name: sharedServicesSubnetName + properties: { + addressPrefix: sharedServicesSubnetAddressPrefix + defaultOutboundAccess: false + privateEndpointNetworkPolicies: enablePrivateDnsAndEndpoints ? 'Disabled' : 'Enabled' + } +} + +// GatewaySubnet is reserved when reserveGatewaySubnet is true so a VPN gateway, +// ExpressRoute gateway, or Azure Virtual WAN can be added later without renumbering +// the hub. The gateway resource itself is not deployed by this module. +resource gatewaySubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = if (reserveGatewaySubnet) { + parent: hubVnet + name: gatewaySubnetName + properties: { + addressPrefix: gatewaySubnetAddressPrefix + defaultOutboundAccess: false + } +} + +resource keyVaultPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + name: keyVaultPrivateDnsZoneName + location: 'global' + tags: tags +} + +resource keyVaultPrivateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + parent: keyVaultPrivateDnsZone + name: '${deploymentPrefix}-kv-link-001' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: hubVnet.id + } + } +} + +resource keyVaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + name: keyVaultPrivateEndpointName + location: primaryLocation + tags: tags + properties: { + subnet: { + id: sharedServicesSubnet.id + } + privateLinkServiceConnections: [ + { + name: keyVaultPrivateLinkConnectionName + properties: { + privateLinkServiceId: keyVaultResourceId + groupIds: [ + 'vault' + ] + requestMessage: 'Private connectivity for the shared platform Key Vault.' + } + } + ] + } +} + +resource keyVaultPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + parent: keyVaultPrivateEndpoint + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'vaultcore' + properties: { + privateDnsZoneId: keyVaultPrivateDnsZone.id + } + } + ] + } +} + +output networkResourceGroupName string = resourceGroup().name +output vnetResourceId string = hubVnet.id +output vnetName string = hubVnet.name +output sharedServicesSubnetResourceId string = sharedServicesSubnet.id +output gatewaySubnetResourceId string = reserveGatewaySubnet ? gatewaySubnet.id : '' +output keyVaultPrivateEndpointResourceId string = enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId) ? keyVaultPrivateEndpoint.id : '' +output keyVaultPrivateDnsZoneResourceId string = enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId) ? keyVaultPrivateDnsZone.id : '' +output keyVaultPrivateEndpointImplemented bool = enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId) diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/expanded-network-profile.bicep b/AzureLandingZoneforNonprofits/infra/modules/networking/expanded-network-profile.bicep new file mode 100644 index 00000000..e1a65987 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/expanded-network-profile.bicep @@ -0,0 +1,73 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the Expanded Platform networking resources.') +param primaryLocation string + +@description('Optional common tags applied to Expanded Platform networking resources.') +param tags object = {} + +@description('Name of the connectivity resource group that hosts the hub network.') +@maxLength(90) +param networkResourceGroupName string = '${deploymentPrefix}-connectivity-rg' + +@description('Shared platform Key Vault resource ID used for the optional private endpoint path.') +param keyVaultResourceId string = '' + +@description('Reserve the GatewaySubnet in the hub VNet so a VPN gateway, ExpressRoute gateway, or Azure Virtual WAN can be added later. This deployment reserves the subnet only; it does not create the gateway resource, public IP, or connection objects.') +param reserveGatewaySubnet bool = false + +@description('Enable private DNS and a private endpoint for the shared platform Key Vault in the hub network.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Address space for the Expanded Platform hub VNet.') +param hubVnetAddressSpace string = '10.30.0.0/16' + +@description('Address prefix for the shared services subnet in the hub VNet.') +param sharedServicesSubnetAddressPrefix string = '10.30.1.0/24' + +@description('Address prefix for the gateway subnet in the hub VNet.') +param gatewaySubnetAddressPrefix string = '10.30.254.0/27' + +var highImpactWarnings = concat( + enablePrivateDnsAndEndpoints ? [ + 'Private connectivity changes the platform access model and may require extra DNS configuration.' + ] : [] +) +var networkingFollowUpActions = concat( + reserveGatewaySubnet ? [ + 'GatewaySubnet was reserved, but no gateway was deployed. Provision the gateway (VPN, ExpressRoute, or Virtual WAN) separately when required.' + ] : [], + enablePrivateDnsAndEndpoints ? [ + 'Validate private DNS resolution for the shared platform Key Vault from the hub network before relying on private-only access.' + ] : [] +) + +module expandedHubNetworkResources 'expanded-hub-network-resources.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-connectivity-network-resources' + scope: resourceGroup(networkResourceGroupName) + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + tags: tags + keyVaultResourceId: keyVaultResourceId + reserveGatewaySubnet: reserveGatewaySubnet + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + hubVnetAddressSpace: hubVnetAddressSpace + sharedServicesSubnetAddressPrefix: sharedServicesSubnetAddressPrefix + gatewaySubnetAddressPrefix: gatewaySubnetAddressPrefix + } +} + +output networkResourceGroupName string = networkResourceGroupName +output vnetResourceId string = expandedHubNetworkResources.outputs.vnetResourceId +output sharedServicesSubnetResourceId string = expandedHubNetworkResources.outputs.sharedServicesSubnetResourceId +output gatewaySubnetResourceId string = expandedHubNetworkResources.outputs.gatewaySubnetResourceId +output keyVaultPrivateEndpointResourceId string = expandedHubNetworkResources.outputs.keyVaultPrivateEndpointResourceId +output keyVaultPrivateDnsZoneResourceId string = expandedHubNetworkResources.outputs.keyVaultPrivateDnsZoneResourceId +output keyVaultPrivateEndpointImplemented bool = expandedHubNetworkResources.outputs.keyVaultPrivateEndpointImplemented +output networkingFollowUpActions array = networkingFollowUpActions +output highImpactWarnings array = highImpactWarnings diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-profile.bicep b/AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-profile.bicep new file mode 100644 index 00000000..fd4ef580 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-profile.bicep @@ -0,0 +1,68 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the Foundation networking resources.') +param primaryLocation string + +@description('Optional common tags applied to Foundation networking resources.') +param tags object = {} + +@description('Shared platform Key Vault resource ID used for the optional private endpoint path.') +param keyVaultResourceId string = '' + +@description('Deploy the optional simple Foundation network baseline.') +param enableSimpleNetwork bool = false + +@description('Enable private DNS and a private endpoint for the shared platform Key Vault. In Foundation this requires the simple network baseline.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Name of the Foundation networking resource group.') +@maxLength(90) +param networkResourceGroupName string = '${deploymentPrefix}-network-rg' + +@description('Address space for the Foundation VNet.') +param vnetAddressSpace string = '10.20.0.0/22' + +@description('Address prefix for the application subnet in the Foundation VNet.') +param applicationSubnetAddressPrefix string = '10.20.1.0/24' + +@description('Address prefix for the private endpoints subnet in the Foundation VNet.') +param privateEndpointsSubnetAddressPrefix string = '10.20.2.0/24' + +var privateKeyVaultConnectivityEnabled = enableSimpleNetwork && enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId) +var networkingFollowUpActions = enablePrivateDnsAndEndpoints && !enableSimpleNetwork ? [ + 'Foundation private Key Vault connectivity requires the simple network baseline. Enable simple networking or disable private connectivity for Key Vault.' +] : [] + +resource foundationNetworkResourceGroup 'Microsoft.Resources/resourceGroups@2024-03-01' = if (enableSimpleNetwork) { + name: networkResourceGroupName + location: primaryLocation + tags: tags +} + +module foundationNetworkResources 'foundation-network-resources.bicep' = if (enableSimpleNetwork) { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-network-resources' + scope: foundationNetworkResourceGroup + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + tags: tags + keyVaultResourceId: keyVaultResourceId + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + vnetAddressSpace: vnetAddressSpace + applicationSubnetAddressPrefix: applicationSubnetAddressPrefix + privateEndpointsSubnetAddressPrefix: privateEndpointsSubnetAddressPrefix + } +} + +output networkResourceGroupName string = enableSimpleNetwork ? foundationNetworkResourceGroup.name : '' +output vnetResourceId string = enableSimpleNetwork ? foundationNetworkResources!.outputs.vnetResourceId : '' +output applicationSubnetResourceId string = enableSimpleNetwork ? foundationNetworkResources!.outputs.applicationSubnetResourceId : '' +output privateEndpointsSubnetResourceId string = privateKeyVaultConnectivityEnabled ? foundationNetworkResources!.outputs.privateEndpointsSubnetResourceId : '' +output keyVaultPrivateEndpointResourceId string = privateKeyVaultConnectivityEnabled ? foundationNetworkResources!.outputs.keyVaultPrivateEndpointResourceId : '' +output keyVaultPrivateDnsZoneResourceId string = privateKeyVaultConnectivityEnabled ? foundationNetworkResources!.outputs.keyVaultPrivateDnsZoneResourceId : '' +output privateKeyVaultConnectivityEnabled bool = privateKeyVaultConnectivityEnabled +output networkingFollowUpActions array = networkingFollowUpActions diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-resources.bicep b/AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-resources.bicep new file mode 100644 index 00000000..b2c0721d --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/foundation-network-resources.bicep @@ -0,0 +1,135 @@ +targetScope = 'resourceGroup' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the Foundation networking resources.') +param primaryLocation string + +@description('Optional common tags applied to Foundation networking resources.') +param tags object = {} + +@description('Shared platform Key Vault resource ID used for the optional private endpoint path.') +param keyVaultResourceId string = '' + +@description('Enable private DNS and a private endpoint for the shared platform Key Vault.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Address space for the Foundation VNet.') +param vnetAddressSpace string = '10.20.0.0/22' + +@description('Address prefix for the application subnet in the Foundation VNet.') +param applicationSubnetAddressPrefix string = '10.20.1.0/24' + +@description('Address prefix for the private endpoints subnet in the Foundation VNet.') +param privateEndpointsSubnetAddressPrefix string = '10.20.2.0/24' + +var foundationVnetName = '${deploymentPrefix}-vnet-001' +var applicationSubnetName = 'application' +var privateEndpointsSubnetName = 'private-endpoints' + +module tagInheritance '../governance/resource-group-tag-inheritance.bicep' = { + name: 'rg-tag-inheritance-${uniqueString(resourceGroup().id)}' + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + } +} +var keyVaultPrivateDnsZoneName = 'privatelink.vaultcore.azure.net' +var keyVaultPrivateEndpointName = '${deploymentPrefix}-kv-pe-001' +var keyVaultPrivateLinkConnectionName = '${deploymentPrefix}-kv-pls-001' + +resource foundationVnet 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: foundationVnetName + location: primaryLocation + tags: tags + properties: { + addressSpace: { + addressPrefixes: [ + vnetAddressSpace + ] + } + } +} + +resource applicationSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = { + parent: foundationVnet + name: applicationSubnetName + properties: { + addressPrefix: applicationSubnetAddressPrefix + defaultOutboundAccess: false + } +} + +resource privateEndpointsSubnet 'Microsoft.Network/virtualNetworks/subnets@2024-05-01' = if (enablePrivateDnsAndEndpoints) { + parent: foundationVnet + name: privateEndpointsSubnetName + properties: { + addressPrefix: privateEndpointsSubnetAddressPrefix + defaultOutboundAccess: false + privateEndpointNetworkPolicies: 'Disabled' + } +} + +resource keyVaultPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + name: keyVaultPrivateDnsZoneName + location: 'global' + tags: tags +} + +resource keyVaultPrivateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + parent: keyVaultPrivateDnsZone + name: '${deploymentPrefix}-kv-link-001' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: foundationVnet.id + } + } +} + +resource keyVaultPrivateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + name: keyVaultPrivateEndpointName + location: primaryLocation + tags: tags + properties: { + subnet: { + id: privateEndpointsSubnet.id + } + privateLinkServiceConnections: [ + { + name: keyVaultPrivateLinkConnectionName + properties: { + privateLinkServiceId: keyVaultResourceId + groupIds: [ + 'vault' + ] + requestMessage: 'Private connectivity for the shared platform Key Vault.' + } + } + ] + } +} + +resource keyVaultPrivateDnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = if (enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId)) { + parent: keyVaultPrivateEndpoint + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'vaultcore' + properties: { + privateDnsZoneId: keyVaultPrivateDnsZone.id + } + } + ] + } +} + +output vnetResourceId string = foundationVnet.id +output applicationSubnetResourceId string = applicationSubnet.id +output privateEndpointsSubnetResourceId string = enablePrivateDnsAndEndpoints ? privateEndpointsSubnet.id : '' +output keyVaultPrivateEndpointResourceId string = enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId) ? keyVaultPrivateEndpoint.id : '' +output keyVaultPrivateDnsZoneResourceId string = enablePrivateDnsAndEndpoints && !empty(keyVaultResourceId) ? keyVaultPrivateDnsZone.id : '' diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/validation/bicepconfig.json b/AzureLandingZoneforNonprofits/infra/modules/networking/validation/bicepconfig.json new file mode 100644 index 00000000..af5a5ecf --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/validation/bicepconfig.json @@ -0,0 +1,5 @@ +{ + "experimentalFeaturesEnabled": { + "assertions": true + } +} \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/infra/modules/networking/validation/foundation-input-validation.bicep b/AzureLandingZoneforNonprofits/infra/modules/networking/validation/foundation-input-validation.bicep new file mode 100644 index 00000000..4553508a --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/networking/validation/foundation-input-validation.bicep @@ -0,0 +1,17 @@ +targetScope = 'subscription' + +@description('True when private DNS and the Key Vault private endpoint were requested in the deployment inputs.') +param enablePrivateDnsAndEndpoints bool = false + +@description('True when the simple Foundation network baseline was requested in the deployment inputs.') +param enableSimpleNetwork bool = false + +// Hard-fail asserts: refuse to deploy with combinations that would otherwise silently fall back to a +// configuration that was not selected. The deployment inputs must be adjusted explicitly. +assert keyVaultPrivateEndpointRequiresSimpleNetwork = !enablePrivateDnsAndEndpoints || enableSimpleNetwork + +output validationState object = { + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + enableSimpleNetwork: enableSimpleNetwork + validationStatus: 'passed' +} diff --git a/AzureLandingZoneforNonprofits/infra/modules/security/README.md b/AzureLandingZoneforNonprofits/infra/modules/security/README.md new file mode 100644 index 00000000..af179e1d --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/security/README.md @@ -0,0 +1,20 @@ +# Security Modules + +This folder contains the Azure Landing Zone V2 security and minimal data-trust baseline. + +Current module: + +- `subscription-security-baseline.bicep`: applies the Defender for Cloud Key Vault and Storage option at subscription scope and emits security-state outputs for the shared platform Key Vault, workload coverage boundaries, and data-trust action guidance + +Defender baseline (`defenderBaseline` parameter): + +- `recommended`: enables Defender for Key Vault and the current Defender for Storage plan in the target subscription. Storage malware scanning and sensitive data discovery extensions are left disabled by this deployment. +- `none`: writes no `Microsoft.Security/pricings` resources, preserving any pre-existing tenant or CSP Defender configuration. + +Defender plans for App Service, SQL Servers, Virtual Machines, and Kubernetes are not enabled by this deployment. Enable them manually in Defender for Cloud after deployment when those workloads exist and recurring charges are approved. + +Implementation notes: + +- The shared platform Key Vault remains part of the existing platform baseline and is not redeployed here. +- Key Vault diagnostics stay integrated through the monitoring baseline. +- Private Key Vault connectivity is reported as an action state until the networking profile implements the private endpoint and private DNS path. \ No newline at end of file diff --git a/AzureLandingZoneforNonprofits/infra/modules/security/subscription-security-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/security/subscription-security-baseline.bicep new file mode 100644 index 00000000..962760d3 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/security/subscription-security-baseline.bicep @@ -0,0 +1,65 @@ +targetScope = 'subscription' + +@description('Shared platform Key Vault resource ID when this subscription has a platform Key Vault.') +param keyVaultResourceId string = '' + +@description('Defender for Cloud baseline. "recommended" enables paid Defender plans for Key Vault and Storage. Storage uses the current DefenderForStorageV2 plan with malware scanning and sensitive data discovery disabled by this deployment. "none" keeps existing paid Defender plan settings unchanged so pre-existing tenant or CSP configuration is preserved. Defender plans for App Service, SQL, Virtual Machines, and Kubernetes are not enabled by this deployment; enable them separately in Defender for Cloud after deployment.') +@allowed([ + 'recommended' + 'none' +]) +param defenderBaseline string = 'none' + +@description('Request stronger private Key Vault isolation. The networking profile implements the private endpoint and private DNS path.') +param enablePrivateDnsAndEndpoints bool = false + +@description('True when the selected networking profile actually implements the requested private endpoint path for the shared platform Key Vault.') +param keyVaultPrivateEndpointImplemented bool = false + +var defenderRecommended = defenderBaseline == 'recommended' +var keyVaultPrivateHardeningStatus = !empty(keyVaultResourceId) ? (enablePrivateDnsAndEndpoints ? (keyVaultPrivateEndpointImplemented ? 'implemented-via-networking-profile' : 'requested-but-not-implemented') : 'not-requested') : 'not-applicable-no-keyvault-in-scope' +var securityFollowUpActions = concat( + !empty(keyVaultResourceId) && !enablePrivateDnsAndEndpoints ? [ + 'Key Vault uses public network access by default. Enable private DNS and the Key Vault private endpoint later only when private-only secret access is required or a compliance review requires it.' + ] : [], + !empty(keyVaultResourceId) && enablePrivateDnsAndEndpoints && !keyVaultPrivateEndpointImplemented ? [ + 'Key Vault private connectivity was requested, but the selected networking profile did not implement the required private endpoint and private DNS path. Complete the networking prerequisite or disable private-only Key Vault access.' + ] : [], + defenderRecommended ? [ + 'Defender for Storage uses the current subscription-level DefenderForStorageV2 plan. Malware scanning and sensitive data discovery are not enabled by this deployment; enable those extensions separately in Defender for Cloud when recurring scanning costs are approved.' + ] : [], + [ + 'This deployment does not enable Defender plans for App Service, SQL Servers, Virtual Machines, or Kubernetes. If those workloads later run in this subscription, enable the matching Defender plans manually in Defender for Cloud after recurring charges are approved.' + 'Document periodic privileged access review expectations, audit and platform log retention, and fundraising or payment-adjacent workload review boundaries in the operations documentation.' + ] +) + +resource keyVaultsPricing 'Microsoft.Security/pricings@2024-01-01' = if (defenderRecommended) { + name: 'KeyVaults' + properties: { + pricingTier: 'Standard' + } +} + +resource storageAccountsPricing 'Microsoft.Security/pricings@2024-01-01' = if (defenderRecommended) { + name: 'StorageAccounts' + properties: { + pricingTier: 'Standard' + subPlan: 'DefenderForStorageV2' + extensions: [ + { + name: 'OnUploadMalwareScanning' + isEnabled: 'False' + } + { + name: 'SensitiveDataDiscovery' + isEnabled: 'False' + } + ] + } +} + +output keyVaultResourceId string = keyVaultResourceId +output keyVaultPrivateHardeningStatus string = keyVaultPrivateHardeningStatus +output defenderBaseline string = defenderBaseline +output securityFollowUpActions array = securityFollowUpActions diff --git a/AzureLandingZoneforNonprofits/infra/modules/shared/README.md b/AzureLandingZoneforNonprofits/infra/modules/shared/README.md new file mode 100644 index 00000000..066df5e3 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/shared/README.md @@ -0,0 +1,15 @@ +# Shared Module Contracts + +This folder contains cross-cutting module contracts used across deployment scenarios. + +Current contracts: + +- `subscription-platform-slice.bicep`: shared subscription-scope slice for platform resource groups, naming inputs, tag merging, and standard slice outputs +- `foundation-platform-baseline.bicep`: shared Foundation subscription baseline that reuses the slice contract without embedding governance behavior +- `resource-group-baseline.bicep`: shared resource-group baseline for core platform resources and consistent resource naming + +Contract rules: + +- `deploymentPrefix` is the source of the supported naming pattern. +- `tags` are merged with the required baseline tags rather than replacing them. +- outputs must be stable and scenario-safe so later workstreams can depend on them. diff --git a/AzureLandingZoneforNonprofits/infra/modules/shared/foundation-platform-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/shared/foundation-platform-baseline.bicep new file mode 100644 index 00000000..f20f03a7 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/shared/foundation-platform-baseline.bicep @@ -0,0 +1,87 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the deployment.') +param primaryLocation string + +@description('Optional additional tags.') +param tags object = {} + +@description('Service owner used for tags.') +param serviceOwner string + +@description('Deploy the optional simple Foundation network baseline.') +param enableSimpleNetwork bool = false + +@description('Enable private DNS and a private endpoint for the shared platform Key Vault. In Foundation this requires the simple network baseline.') +param enablePrivateDnsAndEndpoints bool = false + +@description('Address space for the optional Foundation VNet. Default /22 (1024 addresses) sized for a small NGO and to leave room for future peering without /16 collisions.') +param foundationVnetAddressSpace string = '10.20.0.0/22' + +@description('Address prefix for the application subnet in the optional Foundation VNet.') +param foundationApplicationSubnetAddressPrefix string = '10.20.1.0/24' + +@description('Address prefix for the private endpoints subnet in the optional Foundation VNet.') +param foundationPrivateEndpointsSubnetAddressPrefix string = '10.20.2.0/24' + +@description('Enable purge protection on the platform Key Vault. Defaults to false in Foundation so evaluation deployments can be removed without waiting for the 7-day soft-delete retention to expire. Set to true once the environment holds production secrets that must survive accidental deletion.') +param enableKeyVaultPurgeProtection bool = false + +@description('Optional stable seed for the generated platform Key Vault name. Leave empty to preserve the default deterministic name; set a custom value only when an evaluation deployment must avoid a soft-deleted Key Vault name in the same resource group.') +param keyVaultNameSeed string = '' + +var privateKeyVaultConnectivityEnabled = enableSimpleNetwork && enablePrivateDnsAndEndpoints + +module foundationSlice 'subscription-platform-slice.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-platform-resources' + scope: subscription() + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + serviceOwner: serviceOwner + tags: tags + sliceName: 'platform' + platformResourceGroupName: '${deploymentPrefix}-platform-rg' + keyVaultPublicNetworkAccess: privateKeyVaultConnectivityEnabled ? 'Disabled' : 'Enabled' + enableKeyVaultPurgeProtection: enableKeyVaultPurgeProtection + keyVaultNameSeed: keyVaultNameSeed + createWorkspace: true + createKeyVault: true + } +} + +module foundationNetworking './../networking/foundation-network-profile.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-foundation-network-profile' + scope: subscription() + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + tags: foundationSlice.outputs.effectiveTags + keyVaultResourceId: foundationSlice.outputs.keyVaultResourceId + enableSimpleNetwork: enableSimpleNetwork + enablePrivateDnsAndEndpoints: enablePrivateDnsAndEndpoints + networkResourceGroupName: '${deploymentPrefix}-network-rg' + vnetAddressSpace: foundationVnetAddressSpace + applicationSubnetAddressPrefix: foundationApplicationSubnetAddressPrefix + privateEndpointsSubnetAddressPrefix: foundationPrivateEndpointsSubnetAddressPrefix + } +} + +output primarySubscriptionId string = subscription().subscriptionId +output platformResourceGroupName string = foundationSlice.outputs.platformResourceGroupName +output networkResourceGroupName string = foundationNetworking.outputs.networkResourceGroupName +output logAnalyticsWorkspaceResourceId string = foundationSlice.outputs.logAnalyticsWorkspaceResourceId +output keyVaultResourceId string = foundationSlice.outputs.keyVaultResourceId +output vnetResourceId string = foundationNetworking.outputs.vnetResourceId +output applicationSubnetResourceId string = foundationNetworking.outputs.applicationSubnetResourceId +output privateEndpointsSubnetResourceId string = foundationNetworking.outputs.privateEndpointsSubnetResourceId +output keyVaultPrivateEndpointResourceId string = foundationNetworking.outputs.keyVaultPrivateEndpointResourceId +output keyVaultPrivateDnsZoneResourceId string = foundationNetworking.outputs.keyVaultPrivateDnsZoneResourceId +output privateKeyVaultConnectivityEnabled bool = foundationNetworking.outputs.privateKeyVaultConnectivityEnabled +output networkingFollowUpActions array = foundationNetworking.outputs.networkingFollowUpActions +output effectiveTags object = foundationSlice.outputs.effectiveTags +output effectiveNamePrefix string = foundationSlice.outputs.effectiveNamePrefix diff --git a/AzureLandingZoneforNonprofits/infra/modules/shared/resource-group-baseline.bicep b/AzureLandingZoneforNonprofits/infra/modules/shared/resource-group-baseline.bicep new file mode 100644 index 00000000..ddfade37 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/shared/resource-group-baseline.bicep @@ -0,0 +1,99 @@ +targetScope = 'resourceGroup' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for the baseline resources.') +param primaryLocation string + +@description('Optional common tags.') +param tags object = {} + +@description('Create the shared Log Analytics workspace for this slice.') +param createWorkspace bool = true + +@description('Log Analytics workspace retention in days. Defaults to 90 days to stay cost-conscious with the PerGB2018 included retention tier; increase only when governed security requirements justify the added retention cost.') +@minValue(30) +@maxValue(730) +param logAnalyticsWorkspaceRetentionInDays int = 90 + +@description('Create the shared Key Vault for this slice.') +param createKeyVault bool = true + +@description('Optional stable seed for the generated Key Vault name. Leave empty to preserve the default deterministic name; set a custom value only when an evaluation deployment must avoid a soft-deleted Key Vault name in the same resource group.') +param keyVaultNameSeed string = '' + +@description('Public network access mode for the shared Key Vault.') +param keyVaultPublicNetworkAccess 'Enabled' | 'Disabled' = 'Enabled' + +@description('Enable purge protection on the shared Key Vault. Once enabled, deleted Key Vault contents cannot be permanently removed for 7 days and the setting cannot be turned off retroactively. Recommended for production. This shared module defaults to false; Foundation exposes it as an opt-in, while Expanded Platform enables it for the management Key Vault.') +param enableKeyVaultPurgeProtection bool = false + +@description('Soft-delete retention window for the shared Key Vault, in days. Allowed range 7 to 90. Foundation uses 7 days for evaluation deployments that may be removed. Expanded Platform uses 90 days for the management Key Vault because it is intended to hold platform secrets and runs with purge protection on.') +@minValue(7) +@maxValue(90) +param keyVaultSoftDeleteRetentionInDays int = 7 + +@description('Apply the ServiceOwner tag inheritance policy at this resource-group scope. Defaults to true so resource groups created by this deployment inherit ServiceOwner without affecting existing resource groups outside this deployment.') +param enableTagInheritance bool = true + +var normalizedPrefix = toLower(replace(deploymentPrefix, '-', '')) +var safeBase = empty(normalizedPrefix) ? 'alz' : normalizedPrefix +var prefixLength = length(safeBase) > 12 ? 12 : length(safeBase) +var keyVaultNameSuffix = empty(keyVaultNameSeed) ? substring(uniqueString(resourceGroup().id), 0, 8) : substring(uniqueString(resourceGroup().id, keyVaultNameSeed), 0, 8) +var keyVaultName = '${substring(safeBase, 0, prefixLength)}kv${keyVaultNameSuffix}' +var workspaceName = '${deploymentPrefix}-law-001' +var mergedTags = union(tags, { + ManagedBy: 'AzureLandingZone' + DeploymentPrefix: deploymentPrefix +}) + +resource workspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = if (createWorkspace) { + name: workspaceName + location: primaryLocation + tags: mergedTags + properties: { + retentionInDays: logAnalyticsWorkspaceRetentionInDays + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + sku: { + name: 'PerGB2018' + } + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = if (createKeyVault) { + name: keyVaultName + location: primaryLocation + tags: mergedTags + properties: { + enableRbacAuthorization: true + enablePurgeProtection: enableKeyVaultPurgeProtection ? true : null + enableSoftDelete: true + publicNetworkAccess: keyVaultPublicNetworkAccess + sku: { + family: 'A' + name: 'standard' + } + softDeleteRetentionInDays: keyVaultSoftDeleteRetentionInDays + tenantId: subscription().tenantId + accessPolicies: [] + } +} + +module tagInheritance '../governance/resource-group-tag-inheritance.bicep' = if (enableTagInheritance) { + name: 'rg-tag-inheritance-${uniqueString(resourceGroup().id)}' + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + } +} + +output logAnalyticsWorkspaceResourceId string = createWorkspace ? workspace.id : '' +output workspaceName string = createWorkspace ? workspace.name : '' +output keyVaultResourceId string = createKeyVault ? keyVault.id : '' +output keyVaultName string = createKeyVault ? keyVault.name : '' +output effectiveNamePrefix string = deploymentPrefix +output effectiveTags object = mergedTags diff --git a/AzureLandingZoneforNonprofits/infra/modules/shared/subscription-platform-slice.bicep b/AzureLandingZoneforNonprofits/infra/modules/shared/subscription-platform-slice.bicep new file mode 100644 index 00000000..4a563476 --- /dev/null +++ b/AzureLandingZoneforNonprofits/infra/modules/shared/subscription-platform-slice.bicep @@ -0,0 +1,88 @@ +targetScope = 'subscription' + +@description('Short deployment prefix.') +@maxLength(12) +param deploymentPrefix string + +@description('Primary Azure region for this slice.') +param primaryLocation string + +@description('Service owner value used for tags.') +param serviceOwner string + +@description('Environment value used for tags.') +param environment string = 'platform' + +@description('Logical slice name such as platform, management, or connectivity.') +@maxLength(27) +param sliceName string = 'platform' + +@description('Resource group name for shared slice resources.') +@maxLength(90) +param platformResourceGroupName string + +@description('Create the shared Log Analytics workspace for this slice.') +param createWorkspace bool = true + +@description('Log Analytics workspace retention in days. Defaults to 90 days to stay cost-conscious with the PerGB2018 included retention tier; increase only when governed security requirements justify the added retention cost.') +@minValue(30) +@maxValue(730) +param logAnalyticsWorkspaceRetentionInDays int = 90 + +@description('Create the shared Key Vault for this slice.') +param createKeyVault bool = true + +@description('Optional stable seed for the generated Key Vault name. Leave empty to preserve the default deterministic name; set a custom value only when an evaluation deployment must avoid a soft-deleted Key Vault name in the same resource group.') +param keyVaultNameSeed string = '' + +@description('Public network access mode for the shared Key Vault.') +param keyVaultPublicNetworkAccess 'Enabled' | 'Disabled' = 'Enabled' + +@description('Enable purge protection on the slice Key Vault. Defaults to false in this shared module; Foundation exposes this as an opt-in, while Expanded Platform enables it for the management Key Vault.') +param enableKeyVaultPurgeProtection bool = false + +@description('Soft-delete retention window for the slice Key Vault, in days. Allowed range 7 to 90. Foundation uses 7 days for evaluation deployments that may be removed. Expanded Platform uses 90 days for the management slice.') +@minValue(7) +@maxValue(90) +param keyVaultSoftDeleteRetentionInDays int = 7 + +@description('Optional additional tags.') +param tags object = {} + +var commonTags = union(tags, { + ManagedBy: 'AzureLandingZone' + DeploymentPrefix: deploymentPrefix + ServiceOwner: serviceOwner + Environment: environment + Slice: sliceName +}) + +resource platformRg 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: platformResourceGroupName + location: primaryLocation + tags: commonTags +} + +module platformBaseline 'resource-group-baseline.bicep' = { + name: 'nonprofit-alz-${deploymentPrefix}-${sliceName}-resources' + scope: platformRg + params: { + deploymentPrefix: deploymentPrefix + primaryLocation: primaryLocation + tags: commonTags + createWorkspace: createWorkspace + logAnalyticsWorkspaceRetentionInDays: logAnalyticsWorkspaceRetentionInDays + createKeyVault: createKeyVault + keyVaultNameSeed: keyVaultNameSeed + keyVaultPublicNetworkAccess: keyVaultPublicNetworkAccess + enableKeyVaultPurgeProtection: enableKeyVaultPurgeProtection + keyVaultSoftDeleteRetentionInDays: keyVaultSoftDeleteRetentionInDays + } +} + +output subscriptionId string = subscription().subscriptionId +output platformResourceGroupName string = platformRg.name +output logAnalyticsWorkspaceResourceId string = platformBaseline.outputs.logAnalyticsWorkspaceResourceId +output keyVaultResourceId string = platformBaseline.outputs.keyVaultResourceId +output effectiveTags object = commonTags +output effectiveNamePrefix string = platformBaseline.outputs.effectiveNamePrefix diff --git a/AzureLandingZoneforNonprofits/scenarios.json b/AzureLandingZoneforNonprofits/scenarios.json new file mode 100644 index 00000000..df0572f9 --- /dev/null +++ b/AzureLandingZoneforNonprofits/scenarios.json @@ -0,0 +1,81 @@ +{ + "schemaVersion": "1.0", + "publicSurface": "cli-package", + "scenarios": [ + { + "id": "foundation", + "aliases": [ + "foundation", + "foundation-subscription" + ], + "displayName": "Foundation", + "commandFamily": "sub", + "entryPoint": "infra/entrypoints/direct/foundation-subscription.bicep", + "exampleConfigFile": "examples/commands/foundation.install-config.json", + "defaultParametersFile": "examples/parameters/foundation/foundation.subscription-only.parameters.json", + "cli": { + "classification": "public", + "publicSurface": "cli-package" + }, + "requires": { + "subscription": true, + "managementGroup": false + }, + "warnings": [ + { + "parameter": "monthlyBudgetAmount", + "message": "Creating a Foundation subscription budget uses the subscription billing currency and requires the deployment principal to hold Cost Management Contributor (or Contributor / Owner) on the subscription; the subscription must also be at least ~48 hours old. Without those prerequisites, set monthlyBudgetAmount to 0 to skip the budget step — otherwise the entire deployment will fail.", + "requiresExplicitApproval": false + }, + { + "parameter": "enablePrivateDnsAndEndpoints", + "message": "Private connectivity changes the platform access model and may require extra DNS configuration.", + "requiresExplicitApproval": false + } + ] + }, + { + "id": "expanded-platform", + "aliases": [ + "expanded-platform", + "expanded", + "extended-platform", + "extended" + ], + "displayName": "Expanded Platform", + "commandFamily": "tenant", + "entryPoint": "infra/entrypoints/direct/expanded-platform.bicep", + "exampleConfigFile": "examples/commands/expanded-platform.install-config.json", + "defaultParametersFile": "examples/parameters/expanded-platform/expanded-platform.parameters.json", + "cli": { + "classification": "public", + "publicSurface": "cli-package" + }, + "requires": { + "subscription": false, + "managementGroup": false, + "parameterSubscriptions": [ + "managementSubscriptionId", + "connectivitySubscriptionId" + ] + }, + "warnings": [ + { + "parameter": "monthlyBudgetAmount", + "message": "Creating an Expanded Platform management subscription budget uses the management subscription billing currency and requires the deployment principal to hold Cost Management Contributor (or Contributor / Owner) on that subscription; the subscription must also be at least ~48 hours old. Without those prerequisites, set monthlyBudgetAmount to 0 to skip the budget step — otherwise the entire deployment will fail.", + "requiresExplicitApproval": false + }, + { + "parameter": "reserveGatewaySubnet", + "message": "This option only reserves the GatewaySubnet in the hub network. The Landing Zone does not deploy a VPN gateway, ExpressRoute gateway, or Azure Virtual WAN; provision the gateway separately when hybrid connectivity is required.", + "requiresExplicitApproval": false + }, + { + "parameter": "enablePrivateDnsAndEndpoints", + "message": "Private connectivity changes the platform access model and may require extra DNS configuration.", + "requiresExplicitApproval": false + } + ] + } + ] +} diff --git a/AzureLandingZoneforNonprofits/tsismallarm.json b/AzureLandingZoneforNonprofits/tsismallarm.json deleted file mode 100644 index d412df8e..00000000 --- a/AzureLandingZoneforNonprofits/tsismallarm.json +++ /dev/null @@ -1,707 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-08-01/tenantDeploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "industryPrefix": { - "type": "string", - "maxLength": 10, - "metadata": { - "description": "Provide a prefix (max 10 characters, unique at tenant-scope) for the Management Group hierarchy and other resources created." - } - }, - "industry": { - "type": "string", - "allowedValues": [ - "tsi" - ] - }, - "managementSubscriptionId": { - "type": "string", - "defaultValue": "", - "maxLength": 36, - "metadata": { - "description": "Provide the subscription ID of an existing, empty subscription you want to dedicate for management. If you don't want to bring a subscription, leave this parameter empty as is." - } - }, - "connectivitySubscriptionId": { - "type": "string", - "defaultValue": "", - "maxLength": 36, - "metadata": { - "description": "Provide the subscription ID of an existing, empty subscription you want to dedicate for networking." - } - }, - "enableLogAnalytics": { - "type": "string", - "defaultValue": "No", - "allowedValues": [ - "Yes", - "No" - ], - "metadata": { - "description": "If 'Yes' is selected when also adding a subscription for management, ARM will assign two policies to enable auditing in your environment, into the Log Analytics workspace for platform monitoring. If 'No', it will be ignored." - } - }, - "retentionInDays": { - "type": "string", - "defaultValue": "" - }, - "location": { - "type": "string", - "defaultValue": "[deployment().location]" - }, - "hubName": { - "type": "string", - "metadata": { - "description": "Name of the Virtual Network" - } - }, - "hubAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Virtual Network" - } - }, - "hubSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the Subnet" - } - }, - "hubSubnetAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Subnet" - } - }, - "vpnGWSubnet": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the VPN Gateway Subnet" - } - }, - "recoveryName": { - "type": "string", - "metadata": { - "description": "Name of the Recovery Services Vault" - } - }, - "keyVaultName": { - "type": "string", - "metadata": { - "description": "Name of the Key Vault" - } - }, - "enableDdoS": { - "type": "string", - "defaultValue": "No", - "allowedValues": [ - "Yes", - "No" - ] - }, - "enableDefender": { - "type": "string", - "defaultValue": "No", - "allowedValues": [ - "Yes", - "No" - ] - }, - "enableDefenderLZ": { - "type": "string", - "defaultValue": "No", - "allowedValues": [ - "Yes", - "No" - ] - }, - "spokeName": { - "type": "string", - "metadata": { - "description": "Name of the Virtual Network" - } - }, - "spokeAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Virtual Network" - } - }, - "spokeSubnetName": { - "type": "string", - "metadata": { - "description": "Name of the Subnet" - } - }, - "spokeSubnetAddrPrefix": { - "type": "string", - "metadata": { - "description": "CIDR prefix for the Subnet" - } - }, - "keyVaultNameHub": { - "type": "string", - "metadata": { - "description": "Name of the Key Vault" - } - }, - "recoveryNameSpoke": { - "type": "string", - "metadata": { - "description": "Name of the Recovery Services Vault" - } - } - - }, - "variables": { - "mgmtGroups": { - "management": "[parameters('industryPrefix')]", - "connectivity": "[parameters('industryPrefix')]" - }, - "scopes": { - "managementManagementGroup": "[tenantResourceId('Microsoft.Management/managementGroups/', variables('mgmtGroups').management)]" - }, - "deploymentUris": { - "managementGroups": "[uri(deployment().properties.templateLink.uri, 'core/managementGroupTemplates/mgmtGroupStructure/mgmtGroups.json')]", - "hubNetwork": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/hubNetwork.json')]", - "monitoring": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/logAnalyticsWorkspace.json')]", - "ddosProtection": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/ddosProtection.json')]", - "recoveryVault": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/recoveryServicesVault.json')]", - "keyVault": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/keyVault.json')]", - "vpnGateway": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/vpnGateway.json')]", - "enableDDoS": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/enableDDoS.json')]", - "enableDefender": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/defenderCloud.json')]", - "spokeNetwork": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/spokeNetwork.json')]", - "peeringHubSpoke": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/peeringHubSpoke.json')]", - "peeringSpokeHub": "[uri(deployment().properties.templateLink.uri, 'core/subscriptionTemplates/peeringSpokeHub.json')]", - "customRbacRoleDefinition": "[uri(deployment().properties.templateLink.uri, 'core/managementGroupTemplates/roleDefinitions/Custom-RBACDefinitions.json')]", - "subscriptionPlacement": "[uri(deployment().properties.templateLink.uri, 'core/managementGroupTemplates/subscriptionOrganization/subscriptionOrganization.json')]" - }, - "deploymentSuffix": "[concat('-', deployment().location, guid(parameters('industryPrefix')))]", - "industry": "[parameters('industry')]", - "deploymentNames": { - "mgmtGroupDeploymentName": "[take(concat(variables('industry'), '-Mgs', variables('deploymentSuffix')), 64)]", - "keyVaultName": "[take(concat(variables('industry'), 'kv', variables('deploymentSuffix')), 64)]", - "vpnGatewayName": "[take(concat(variables('industry'), '-vpnGW', variables('deploymentSuffix')), 64)]", - "enableDDoS": "[take(concat(variables('industry'), '-ddos-plan', variables('deploymentSuffix')), 64)]", - "vnetConnectivityHubDeploymentName": "[take(concat(variables('industry'), '-Hub', variables('deploymentSuffix')), 64)]", - "vnetConnectivitySpokeDeploymentName": "[take(concat(variables('industry'), '-Spoke', variables('deploymentSuffix')), 64)]", - "recoveryVaultName": "[take(concat(variables('industry'), '-rsv', variables('deploymentSuffix')), 64)]", - "customRbacDeploymentName": "[take(concat(variables('industry'), '-RoleDefinitions', variables('deploymentSuffix')), 64)]", - "mgmtSubscriptionPlacement": "[take(concat(variables('industry'), '-MgmtSub', variables('deploymentSuffix')), 64)]", - "connectivitySubscriptionPlacement": "[take(concat(variables('industry'), '-ConnectivitySub', variables('deploymentSuffix')), 64)]", - "monitoringDeploymentName": "[take(concat(variables('industry'), '-Monitoring', variables('deploymentSuffix')), 64)]", - "defenderEndpointPolicyDeploymentName": "[take(concat(variables('industry'), '-DefenderEndpoint', variables('deploymentSuffix')), 64)]", - "identityPeeringDeploymentName": "[take(concat(variables('industry'), '-IDPeeringHub', variables('deploymentSuffix')), 64)]", - "identityPeeringDeploymentName2": "[take(concat(variables('industry'), '-IDPeeringSpoke', variables('deploymentSuffix')), 64)]" - }, - "platformRgNames": { - "mgmtRg": "[concat(parameters('industryPrefix'), '-mgmt')]", - "userAssignedIdentity": "[concat(parameters('industryPrefix'), '-policy-identity')]", - "connectivityRg": "[concat(parameters('industryPrefix'), '-vnethub-', parameters('location'))]", - "ddosRg": "[concat(parameters('industryPrefix'), '-ddos')]", - "privateDnsRg": "[concat(parameters('industryPrefix'), '-privatedns')]", - "identityVnetRg": "[concat(parameters('industryPrefix'), '-vnet-', parameters('location'))]", - "lzVnetRg": "[concat(parameters('industryPrefix'), '-vnet-', parameters('location'))]", - "logNwRg": "[concat(parameters('industryPrefix'), '-rglz')]" - }, - "platformResourceNames": { - "logAnalyticsWorkspace": "[concat(parameters('industryPrefix'), '-law-001')]", - "ddosName": "[concat(parameters('industryPrefix'), '-ddos-', parameters('location'))]" - - }, - "roleDefinitions": { - "networkContributor": "4d97b98b-1d4f-4787-a291-c67834d212e7", - "contributor": "b24988ac-6180-42a0-ab88-20f7382dd24c" - } - }, - "resources": [ - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2019-10-01", - "name": "[variables('deploymentNames').mgmtGroupDeploymentName]", - "location": "[deployment().location]", - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').managementGroups]" - }, - "parameters": { - "topLevelManagementGroupPrefix": { - "value": "[parameters('industryPrefix')]" - } - } - } - }, - { - "type": "Microsoft.Resources/deployments", - "apiVersion": "2019-10-01", - "name": "[variables('deploymentNames').customRbacDeploymentName]", - "location": "[deployment().location]", - "scope": "[concat('Microsoft.Management/managementGroups/', parameters('industryPrefix'))]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtGroupDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').customRbacRoleDefinition]" - }, - "parameters": { - "topLevelManagementGroupPrefix": { - "value": "[parameters('industryPrefix')]" - } - } - } - }, - { - "condition": "[not(empty(parameters('managementSubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').mgmtSubscriptionPlacement]", - "location": "[deployment().location]", - "scope": "[variables('scopes').managementManagementGroup]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtGroupDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').subscriptionPlacement]" - }, - "parameters": { - "targetManagementGroupId": { - "value": "[variables('mgmtGroups').management]" - }, - "subscriptionId": { - "value": "[parameters('managementSubscriptionId')]" - } - } - } - }, - { - "condition": "[not(empty(parameters('connectivitySubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').connectivitySubscriptionPlacement]", - "location": "[deployment().location]", - "scope": "[variables('scopes').managementManagementGroup]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtGroupDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').subscriptionPlacement]" - }, - "parameters": { - "targetManagementGroupId": { - "value": "[variables('mgmtGroups').connectivity]" - }, - "subscriptionId": { - "value": "[parameters('connectivitySubscriptionId')]" - } - } - } - }, - { - "condition": "[and(equals(parameters('enableLogAnalytics'), 'Yes'), not(empty(parameters('managementSubscriptionId'))))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').monitoringDeploymentName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtSubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').monitoring]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "workspaceName": { - "value": "[variables('platformResourceNames').logAnalyticsWorkspace]" - }, - "workspaceRegion": { - "value": "[deployment().location]" - }, - "retentionInDays": { - "value": "[parameters('retentionInDays')]" - } - } - } - }, - { - "condition": "[not(empty(parameters('managementSubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').vnetConnectivityHubDeploymentName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtSubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').hubNetwork]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "hubName": { - "value": "[parameters('hubName')]" - }, - "hubRegion": { - "value": "[deployment().location]" - }, - "hubAddrPrefix": { - "value": "[parameters('hubAddrPrefix')]" - }, - "hubSubnetName": { - "value": "[parameters('hubSubnetName')]" - }, - "hubSubnetAddrPrefix": { - "value": "[parameters('hubSubnetAddrPrefix')]" - }, - "vpnGWSubnet": { - "value": "[parameters('vpnGWSubnet')]" - } - } - } - }, - { - "condition": "[not(empty(parameters('managementSubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').vpnGatewayName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').vnetConnectivityHubDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').vpnGateway]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "hubName": { - "value": "[parameters('hubName')]" - }, - "vpnGWRegion": { - "value": "[deployment().location]" - }, - "gatewaySubnetName": { - "value": "GatewaySubnet" - }, - "newPublicIpAddressName": { - "value": "vpn-gw-imc-001-PIP-02" - }, - "subscriptionId":{ - "value": "[parameters('managementSubscriptionId')]" - }, - "resourceGroupName":{ - "value": "[concat(parameters('industryPrefix'),'-mgmt')]" - } - } - } - }, - { - "condition": "[not(empty(parameters('managementSubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').recoveryVaultName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtSubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').recoveryVault]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "recoveryName": { - "value": "[parameters('recoveryName')]" - }, - "rsvRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[not(empty(parameters('connectivitySubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "recoveryVault-spoke", - "location": "[deployment().location]", - "subscriptionId": "[parameters('connectivitySubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').connectivitySubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').recoveryVault]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').logNwRg]" - }, - "recoveryName": { - "value": "[parameters('recoveryNameSpoke')]" - }, - "rsvRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[and(equals(parameters('enableDdoS'), 'Yes'), not(empty(parameters('managementSubscriptionId'))))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').enableDDoS]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').vnetConnectivityHubDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').enableDDoS]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "ddosName": { - "value": "[variables('platformResourceNames').ddosName]" - }, - "ddosRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[and(equals(parameters('enableDefender'), 'Yes'), not(empty(parameters('managementSubscriptionId'))))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').defenderEndpointPolicyDeploymentName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').vnetConnectivityHubDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').enableDefender]" - }, - "parameters": { - "defenderRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[not(empty(parameters('managementSubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "keyVaultName-Hub", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtSubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').keyVault]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultNameHub')]" - }, - "kvRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[not(empty(parameters('connectivitySubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').keyVaultName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('connectivitySubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').connectivitySubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').keyVault]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').logNwRg]" - }, - "keyVaultName": { - "value": "[parameters('keyVaultName')]" - }, - "kvRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[not(empty(parameters('connectivitySubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').vnetConnectivitySpokeDeploymentName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('connectivitySubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').connectivitySubscriptionPlacement)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').spokeNetwork]" - }, - "parameters": { - "rgName": { - "value": "[variables('platformRgNames').logNwRg]" - }, - "spokeName": { - "value": "[parameters('spokeName')]" - }, - "spokeRegion": { - "value": "[deployment().location]" - }, - "spokeAddrPrefix": { - "value": "[parameters('spokeAddrPrefix')]" - }, - "spokeSubnetName": { - "value": "[parameters('spokeSubnetName')]" - }, - "spokeSubnetAddrPrefix": { - "value": "[parameters('spokeSubnetAddrPrefix')]" - } - } - } - }, - { - "condition": "[not(empty(parameters('managementSubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').identityPeeringDeploymentName2]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('managementSubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtSubscriptionPlacement)]", - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').connectivitySubscriptionPlacement)]", - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').vnetConnectivitySpokeDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').peeringHubSpoke]" - }, - "parameters": { - "hubVnetResourceGroup": { - "value": "[variables('platformRgNames').mgmtRg]" - }, - "hubVnetName": { - "value": "[parameters('hubName')]" - }, - "spokeVnetResourceId": { - "value": "[concat('/subscriptions/', parameters('connectivitySubscriptionId'), '/resourceGroups/', parameters('industryPrefix'), '-rglz/providers/Microsoft.Network/virtualNetworks/', parameters('spokeName'))]" - }, - "peeringRegion": { - "value": "[deployment().location]" - } - } - } - }, - { - "condition": "[not(empty(parameters('connectivitySubscriptionId')))]", - "type": "Microsoft.Resources/deployments", - "apiVersion": "2020-10-01", - "name": "[variables('deploymentNames').identityPeeringDeploymentName]", - "location": "[deployment().location]", - "subscriptionId": "[parameters('connectivitySubscriptionId')]", - "dependsOn": [ - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').connectivitySubscriptionPlacement)]", - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').mgmtSubscriptionPlacement)]", - "[resourceId('Microsoft.Resources/deployments', variables('deploymentNames').vnetConnectivityHubDeploymentName)]" - ], - "properties": { - "mode": "Incremental", - "templateLink": { - "contentVersion": "1.0.0.0", - "uri": "[variables('deploymentUris').peeringSpokeHub]" - }, - "parameters": { - "spokeVnetResourceGroup": { - "value": "[variables('platformRgNames').logNwRg]" - }, - "hubVnetResourceId": { - "value": "[concat('/subscriptions/', parameters('managementSubscriptionId'), '/resourceGroups/', parameters('industryPrefix'), '-mgmt/providers/Microsoft.Network/virtualNetworks/', parameters('hubName'))]" - }, - "spokeVnetName": { - "value": "[parameters('spokeName')]" - }, - "peeringRegion": { - "value": "[deployment().location]" - } - } - } - } - ], - "outputs": { - "deployment": { - "type": "string", - "value": "[concat(deployment().name, ' has successfully deployed.')]" - } - } -} diff --git a/AzureLandingZoneforNonprofits/tsismallportal.json b/AzureLandingZoneforNonprofits/tsismallportal.json deleted file mode 100644 index 7e837081..00000000 --- a/AzureLandingZoneforNonprofits/tsismallportal.json +++ /dev/null @@ -1,657 +0,0 @@ -{ - "$schema": "", - "view": { - "kind": "Form", - "properties": { - "title": "Azure Landing Zone for Nonprofits", - "isWizard": false, - "steps": [ - { - "name": "basics", - "label": "Deployment location", - "elements": [ - { - "name": "resourceScope", - "type": "Microsoft.Common.ResourceScope" - } - ] - }, - { - "name": "lzSettings", - "label": "Management Group and Subscription Organization", - "subLabel": { - "preValidation": "Provide a prefix for the management group structure that will be created.", - "postValidation": "Done" - }, - "bladeTitle": "Company prefix", - "elements": [ - { - "name": "info", - "type": "Microsoft.Common.InfoBox", - "visible": true, - "options": { - "text": "To deploy the following configurations it is required to have access at the tenant root (/) scope. Visit this link to ensure you have the appropriate RBAC permission to complete the deployment", - "uri": "https://docs.microsoft.com/azure/role-based-access-control/elevate-access-global-admin", - "style": "Info" - } - }, - { - "name": "mgSection", - "type": "Microsoft.Common.Section", - "label": "Management Groups", - "elements": [ - { - "name": "mgmtGroup", - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "Here will be created the management group hierarchy under the Tenant Root Group with the prefix provided at this step, which will be used to establish a proven architecture for subscription organization and policy driven governance at scale." - } - }, - { - "name": "esMgmtGroup", - "type": "Microsoft.Common.TextBox", - "label": "Management Group prefix", - "toolTip": "Provide a prefix (max 10 characters, unique at tenant-scope) for the Management Group hierarchy and other resources created as part of Azure Landing Zone for Nonprofits.", - "defaultValue": "", - "constraints": { - "required": true, - "regex": "^[a-z0-9A-Z-]{1,10}$", - "validationMessage": "The prefix must be 1-10 characters." - } - } - ], - "visible": true - }, - { - "name": "rpRegistrationApi", - "type": "Microsoft.Solutions.ArmApiControl", - "request": { - "method": "POST", - "path": "[concat('/providers/Microsoft.Management/managementGroups/',steps('basics').resourceScope.tenant.tenantId, '/providers/Microsoft.PolicyInsights/register?api-version=2021-04-01')]" - } - }, - { - "name": "subSection", - "type": "Microsoft.Common.Section", - "label": "Subscription Organization", - "elements": [ - { - "name": "subOrg", - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "It is recommended to use a dedidated subscriptions for the Azure platform functionality, such as Security, Governance, Compliance, Network Connectivity, and Identity and Access. This enables the organization to scale the Azure platform and the workloads in the landing zones independently regardless of future scale-point." - } - }, - { - "name": "subOrgsOption", - "type": "Microsoft.Common.OptionsGroup", - "label": "Select dedicated subscriptions or a single subscription for Azure Landing Zone for Nonprofit platform resources", - "defaultValue": "Dedicated (recommended)", - "toolTip": "Dedicated subscriptions will require separate Azure subscriptions for platform resources and is the recommended option for production environments. The single subscription option will deploy all platform resources on a single subscription.", - "constraints": { - "allowedValues": [ - { - "label": "Dedicated (recommended)", - "value": "Dedicated" - }, - { - "label": "Single", - "value": "Single" - } - ] - }, - "visible": true - } - ], - "visible": false - }, - { - "name": "esSingleSubSection", - "type": "Microsoft.Common.Section", - "label": "Single platform subscription", - "elements": [ - { - "name": "subWarning", - "type": "Microsoft.Common.InfoBox", - "visible": true, - "options": { - "icon": "Warning", - "text": "Dedicated subscriptions are recommended for the various platform components to ensure scale, sustainability, and segregation of duties, and especially around networking. However, a single subscription can also be used in case this is not a concern (e.g., small organizations, or testing purposes).", - "uri": "https://docs.microsoft.com/azure/cloud-adoption-framework/ready/enterprise-scale/management-group-and-subscription-organization" - } - }, - { - "name": "singleSubText", - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "Select the single subscription that will be used for all platform resources during deployment, for security, logging, connectivity, and identity." - } - }, - { - "type": "Microsoft.Common.SubscriptionSelector", - "name": "esSingleSub", - "label": "Single platform subscription" - } - ], - "visible": "[equals(steps('lzSettings').subSection.subOrgsOption, 'Single')]" - } - ] - }, - { - "name": "esGoalState", - "label": "Management and Connectivity", - "subLabel": { - "preValidation": "", - "postValidation": "" - }, - "bladeTitle": "lzGs", - "elements": [ - { - "type": "Microsoft.Common.Section", - "name": "imcSection", - "label": "Identity, Management, and Connectivity (IMC) Subscription", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "IMC refers to the core Azure components that manage and secure the communication and operations between different resources. 'Identity' relates to authentication and authorization services ensuring secure access to resources. 'Management' pertains to overseeing the lifecycle of resources, including monitoring and maintenance through tools like Azure Monitor and Log Analytics. 'Connectivity' involves networking aspects that ensure reliable and secure connections between Azure resources, including virtual networks, subnets, and VPNs as configured in Hub Network Configurations." - } - } - ] - }, - { - "name": "multiPlatformMgmtSub", - "type": "Microsoft.Common.InfoBox", - "visible": "[not(equals(steps('lzSettings').subSection.subOrgsOption, 'Single'))]", - "options": { - "text": "To enable platform management and monitoring, you must allocate a dedicated Azure Subscription. Please note, this Subscription will be moved to the platform Management Group, and ARM will deploy a Log Analytics workspace and requisite settings. We recommend using a new Subscription with no existing resources. Note that Azure Policy will be used to govern the configuration for the platform at scale.", - "uri": "https://docs.microsoft.com/azure/cloud-adoption-framework/ready/enterprise-scale/management-and-monitoring", - "style": "Info" - } - }, - { - "name": "singlePlatformMgmtSub", - "type": "Microsoft.Common.InfoBox", - "visible": "[equals(steps('lzSettings').subSection.subOrgsOption, 'Single')]", - "options": { - "text": "To enable management and monitoring, you can configure core infra such as Log Analytics and additional monitoring solutions to your dedicated platform subscription. Note that Azure Policy will be used to govern the configuration for the platform at scale.", - "uri": "https://docs.microsoft.com/azure/cloud-adoption-framework/ready/enterprise-scale/management-and-monitoring", - "style": "Info" - } - }, - { - "name": "esMgmtSubSection", - "type": "Microsoft.Common.Section", - "label": "Management subscription", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "Select the Azure subscription from the dropdown menu below. All resources and services configured in this deployment tab will be created within the selected subscription. This ensures that the deployment and management of resources are centralized in a single subscription, facilitating easier management and potential cost optimizations." - } - }, - { - "type": "Microsoft.Common.SubscriptionSelector", - "name": "esMgmtSub", - "label": "Management subscription" - } - ], - "visible": true - }, - { - "name": "azMonSection", - "type": "Microsoft.Common.Section", - "label": "Azure Monitor", - "elements": [ - { - "name": "azMonText", - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "Azure Monitor with Log Analytics provides the core infrastructure to enable platform observability, security, and log retention.", - "link": { - "label": "Learn more", - "uri": "https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-overview" - } - } - }, - { - "name": "esLogAnalytics", - "type": "Microsoft.Common.OptionsGroup", - "label": "Deploy Log Analytics workspace and enable monitoring for your platform and resources", - "defaultValue": "Yes (recommended)", - "toolTip": "If 'Yes' is selected when also adding a subscription for management, Log Analytics workspace will be created in the dedicated subscription and enable additional configuration options in the deployment wizard.", - "constraints": { - "allowedValues": [ - { - "label": "Yes (recommended)", - "value": "Yes" - }, - { - "label": "No", - "value": "No" - } - ] - }, - "visible": true - }, - { - "name": "esLogRetention", - "type": "Microsoft.Common.Slider", - "min": 30, - "max": 730, - "label": "Log Analytics Data Retention (days)", - "subLabel": "Days", - "defaultValue": 30, - "showStepMarkers": false, - "toolTip": "Select retention days for Azure logs. Default is 30 days. If longer retention is required, you can optionally configure Log Analytics data export to a Storage Account or an Event Hub namespace.", - "constraints": { - "required": false - }, - "visible": "[equals(steps('esGoalState').azMonSection.esLogAnalytics,'Yes')]" - } - ], - "visible": true - }, - { - "name": "hubNetworkConfigurations", - "type": "Microsoft.Common.Section", - "label": "Hub Network Configurations", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "hubTopologyDescription", - "visible": true, - "options": { - "text": "A Hub networking topology in Azure serves as a central point of connectivity to many spoke networks. The hub acts as a central network that contains shared services accessible across different workloads in the spokes. Common services deployed in the hub include network virtual appliances, firewalls, and gateways. This central structure simplifies network management, improves security, and enhances data flow efficiency across networks. As the deployment progresses, this hub will be integrated with spoke networks to create a comprehensive network topology, facilitating efficient data transfer and centralized security management." - } - }, - { - "name": "vnetName", - "type": "Microsoft.Common.TextBox", - "label": "Virtual Network Name", - "toolTip": "Enter the name of the VNet", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", - "validationMessage": "The VNet name must start and end with an alphanumeric character and can contain hyphens." - } - }, - { - "name": "vnetPrefix", - "type": "Microsoft.Common.TextBox", - "label": "Virtual Network Address Prefix", - "toolTip": "Enter the CIDR address prefix for the VNet", - "constraints": { - "required": true, - "regex": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", - "validationMessage": "Enter a valid CIDR notation." - } - }, - { - "name": "subnetName", - "type": "Microsoft.Common.TextBox", - "label": "Subnet Name", - "toolTip": "Enter the name of the subnet", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", - "validationMessage": "The subnet name must start and end with an alphanumeric character and can contain hyphens." - } - }, - { - "name": "subnetPrefix", - "type": "Microsoft.Common.TextBox", - "label": "Subnet Address Prefix", - "toolTip": "Enter the CIDR address prefix for the subnet", - "constraints": { - "required": true, - "regex": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", - "validationMessage": "Enter a valid CIDR notation." - } - }, - { - "type": "Microsoft.Common.TextBlock", - "name": "vpnGatewayDescription", - "visible": true, - "options": { - "text": "A VPN Gateway is a specific type of virtual network gateway in Azure, designed to send encrypted traffic between an Azure virtual network and an on-premises location over the public Internet. It is used to establish secure, cross-premises connectivity, enabling Azure resources to communicate securely with external networks. VPN Gateways facilitate various connectivity scenarios including site-to-site VPNs, point-to-site VPNs, and VNet-to-VNet connections, thereby enhancing business continuity by providing a reliable and secure link for remote access to resources." - } - }, - { - "name": "vpnGatewaySubnet", - "type": "Microsoft.Common.TextBox", - "label": "VPN Gateway Subnet", - "toolTip": "Enter the CIDR address prefix for the VPN Gateway subnet (typically x.x.x.x/27)", - "constraints": { - "required": true, - "regex": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", - "validationMessage": "Enter a valid CIDR notation, typically a /27 for VPN gateways." - } - } - ] - }, - { - "name": "networkSettings", - "type": "Microsoft.Common.Section", - "label": "Network Security Settings", - "bladeTitle": "Network Security Settings", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "ddosProtectionDescription", - "visible": true, - "options": { - "text": "Azure DDoS Protection Standard provides enhanced DDoS mitigation capabilities to defend your Azure resources from DDoS attacks. DDoS Protection Standard offers cost protection by preventing scaling of resources during an attack, thus mitigating potential financial impacts. This layer of security is crucial for maintaining availability and operational resilience against widespread network threats." - } - }, - { - "name": "enableDDoS", - "type": "Microsoft.Common.OptionsGroup", - "label": "Enable DDoS Protection", - "toolTip": "Select whether to enable DDoS Protection", - "defaultValue": "Yes, DDoS Network Protection (recommended)", - "constraints": { - "allowedValues": [ - { - "label": "Yes, DDoS Network Protection (recommended)", - "value": "Yes" - }, - { - "label": "No", - "value": "No" - } - ] - }, - "visible": true - } - ] - }, - { - "name": "security", - "type": "Microsoft.Common.Section", - "label": "Cloud Security Settings", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "defenderForCloudDescription", - "visible": true, - "options": { - "text": "Microsoft Defender for Cloud Standard offers integrated security management and threat protection for your Azure services and hybrid environments. It automatically applies security policies to help secure your services against threats and provides continuous security assessment and actionable recommendations. This standard deployment enhances your security posture by integrating intelligent threat detection and response capabilities, helping you to prevent, detect, and respond to security threats in real time." - } - }, - { - "name": "enableDefender", - "type": "Microsoft.Common.OptionsGroup", - "label": "Enable Microsoft Defender for Cloud ", - "toolTip": "Select whether to enable Microsoft Defender for Cloud", - "defaultValue": "Yes, enable Defender for Cloud (recommended)", - "constraints": { - "allowedValues": [ - { - "label": "Yes, enable Defender for Cloud (recommended)", - "value": "Yes" - }, - { - "label": "No", - "value": "No" - } - ] - }, - "visible": true - } - ] - }, - { - "name": "backupAndRecovery", - "type": "Microsoft.Common.Section", - "label": "Backup and Recovery", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "recoveryServicesVaultDescription", - "visible": true, - "options": { - "text": "Azure Recovery Services Vault is a comprehensive solution for data backup and disaster recovery. It is designed to protect your Azure and on-premises resources by enabling both backup and site recovery services. This centralized vault provides a unified approach to manage your backup policies, monitor backup health, and recover services swiftly in case of data loss or corruption. It simplifies the management of your recovery points, scales on demand to meet your data protection needs, and ensures secure and reliable data storage and recovery capabilities." - } - }, - { - "name": "recoveryServicesVaultName", - "type": "Microsoft.Common.TextBox", - "label": "Recovery Services Vault Name", - "toolTip": "Enter the name for the Recovery Services Vault", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", - "validationMessage": "The name must start and end with an alphanumeric character and can contain hyphens but no spaces." - } - } - ] - }, - { - "name": "keyVaultConfiguration", - "type": "Microsoft.Common.Section", - "label": "Secrets and Keys Management", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "keyVaultDescription", - "visible": true, - "options": { - "text": "Azure Key Vault is a secure and scalable service that helps safeguard cryptographic keys and secrets used by cloud applications and services. By centralizing the storage of these sensitive assets, Key Vault enables you to control their distribution, manage access with fine-grained permissions, and monitor their usage through auditable logs. It supports automatic key rotation, integration with Azure Active Directory, and compliance with stringent industry standards, ensuring that your encryption keys and other secrets are managed securely and efficiently." - } - }, - { - "name": "keyVaultName", - "type": "Microsoft.Common.TextBox", - "label": "Key Vault Name", - "toolTip": "Enter the name for the Azure Key Vault", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]{2,23}[a-zA-Z0-9]$", - "validationMessage": "The Key Vault name must be 3-24 characters long, begin with a letter, end with an alphanumeric character and can contain hyphens." - } - } - ] - } - ] - }, - { - "name": "esLZGoalState", - "label": "Landing Zone", - "subLabel": { - "preValidation": "", - "postValidation": "" - }, - "bladeTitle": "lzGs", - "elements": [ - { - "name": "esNwSubSection", - "type": "Microsoft.Common.Section", - "label": "Landing Zone subscription", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "subscriptionWarning", - "visible": true, - "options": { - "text": "Please ensure that the subscription selected here is different from the one used for the Management and Connectivity configurations. This separation ensures optimal resource management and security compliance across different operational environments." - } - }, - { - "type": "Microsoft.Common.SubscriptionSelector", - "name": "esNwSub", - "label": "Connectivity subscription" - } - ], - "visible": true - }, - { - "name": "spokeNetworkConfigurations", - "type": "Microsoft.Common.Section", - "label": "Spoke Network Configurations", - "elements": [ - { - "name": "vnetName", - "type": "Microsoft.Common.TextBox", - "label": "Virtual Network Name", - "toolTip": "Enter the name of the VNet", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", - "validationMessage": "The VNet name must start and end with an alphanumeric character and can contain hyphens." - } - }, - { - "name": "vnetPrefix", - "type": "Microsoft.Common.TextBox", - "label": "Virtual Network Address Prefix", - "toolTip": "Enter the CIDR address prefix for the VNet", - "constraints": { - "required": true, - "regex": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", - "validationMessage": "Enter a valid CIDR notation." - } - }, - { - "name": "subnetName", - "type": "Microsoft.Common.TextBox", - "label": "Subnet Name", - "toolTip": "Enter the name of the subnet", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", - "validationMessage": "The subnet name must start and end with an alphanumeric character and can contain hyphens." - } - }, - { - "name": "subnetPrefix", - "type": "Microsoft.Common.TextBox", - "label": "Subnet Address Prefix", - "toolTip": "Enter the CIDR address prefix for the subnet", - "constraints": { - "required": true, - "regex": "^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$", - "validationMessage": "Enter a valid CIDR notation." - } - } - ] - }, - { - "name": "keyVaultConfiguration", - "type": "Microsoft.Common.Section", - "label": "Secrets and Keys Management", - "elements": [ - { - "name": "keyVaultName", - "type": "Microsoft.Common.TextBox", - "label": "Key Vault Name", - "toolTip": "Enter the name for the Azure Key Vault", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]{2,23}[a-zA-Z0-9]$", - "validationMessage": "The Key Vault name must be 3-24 characters long, begin with a letter, end with an alphanumeric character and can contain hyphens." - } - } - ] - }, - { - "name": "security", - "type": "Microsoft.Common.Section", - "label": "Cloud Security Settings", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "defenderForCloudDescription", - "visible": true, - "options": { - "text": "Microsoft Defender for Cloud Standard offers integrated security management and threat protection for your Azure services and hybrid environments. It automatically applies security policies to help secure your services against threats and provides continuous security assessment and actionable recommendations. This standard deployment enhances your security posture by integrating intelligent threat detection and response capabilities, helping you to prevent, detect, and respond to security threats in real time." - } - }, - { - "name": "enableDefender", - "type": "Microsoft.Common.OptionsGroup", - "label": "Enable Microsoft Defender for Cloud ", - "toolTip": "Select whether to enable Microsoft Defender for Cloud", - "defaultValue": "Yes, enable Defender for Cloud (recommended)", - "constraints": { - "allowedValues": [ - { - "label": "Yes, enable Defender for Cloud (recommended)", - "value": "Yes" - }, - { - "label": "No", - "value": "No" - } - ] - }, - "visible": true - } - ] - }, - { - "name": "backupAndRecovery", - "type": "Microsoft.Common.Section", - "label": "Backup and Recovery", - "elements": [ - { - "type": "Microsoft.Common.TextBlock", - "name": "recoveryServicesVaultDescription", - "visible": true, - "options": { - "text": "Azure Recovery Services Vault is a comprehensive solution for data backup and disaster recovery. It is designed to protect your Azure and on-premises resources by enabling both backup and site recovery services. This centralized vault provides a unified approach to manage your backup policies, monitor backup health, and recover services swiftly in case of data loss or corruption. It simplifies the management of your recovery points, scales on demand to meet your data protection needs, and ensures secure and reliable data storage and recovery capabilities." - } - }, - { - "name": "recoveryServicesVaultName", - "type": "Microsoft.Common.TextBox", - "label": "Recovery Services Vault Name", - "toolTip": "Enter the name for the Recovery Services Vault", - "constraints": { - "required": true, - "regex": "^[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9]$", - "validationMessage": "The name must start and end with an alphanumeric character and can contain hyphens but no spaces." - } - } - ] - } - ] - } - ] - }, - "outputs": { - "parameters": { - "industry": "tsi", - "industryPrefix": "[steps('lzSettings').mgSection.esMgmtGroup]", - "managementSubscriptionId": "[steps('esGoalState').esMgmtSubSection.esMgmtSub.subscriptionId]", - "enableLogAnalytics": "[steps('esGoalState').azMonSection.esLogAnalytics]", - "retentionInDays": "[string(steps('esGoalState').azMonSection.esLogRetention)]", - "hubName": "[steps('esGoalState').hubNetworkConfigurations.vnetName]", - "hubAddrPrefix": "[steps('esGoalState').hubNetworkConfigurations.vnetPrefix]", - "hubSubnetName": "[steps('esGoalState').hubNetworkConfigurations.subnetName]", - "hubSubnetAddrPrefix": "[steps('esGoalState').hubNetworkConfigurations.subnetPrefix]", - "vpnGWSubnet": "[steps('esGoalState').hubNetworkConfigurations.vpnGatewaySubnet]", - "recoveryName": "[steps('esGoalState').backupAndRecovery.recoveryServicesVaultName]", - "enableDefender": "[steps('esGoalState').security.enableDefender]", - "enableDDoS": "[steps('esGoalState').networkSettings.enableDDoS]", - "keyVaultNameHub": "[steps('esGoalState').keyVaultConfiguration.keyVaultName]", - "connectivitySubscriptionId": "[steps('esLZGoalState').esNwSubSection.esNwSub.subscriptionId]", - "enableDefenderLZ": "[steps('esLZGoalState').security.enableDefender]", - "keyVaultName": "[steps('esLZGoalState').keyVaultConfiguration.keyVaultName]", - "spokeName": "[steps('esLZGoalState').spokeNetworkConfigurations.vnetName]", - "spokeAddrPrefix": "[steps('esLZGoalState').spokeNetworkConfigurations.vnetPrefix]", - "spokeSubnetName": "[steps('esLZGoalState').spokeNetworkConfigurations.subnetName]", - "spokeSubnetAddrPrefix": "[steps('esLZGoalState').spokeNetworkConfigurations.subnetPrefix]", - "recoveryNameSpoke": "[steps('esLZGoalState').backupAndRecovery.recoveryServicesVaultName]" - }, - "kind": "Tenant", - "location": "[steps('basics').resourceScope.location.name]" - } - } -} \ No newline at end of file From 7670e819ecf2ca80e3f9dc62852da376719eb0d1 Mon Sep 17 00:00:00 2001 From: Lukas Domin Date: Wed, 20 May 2026 09:57:38 +0200 Subject: [PATCH 2/3] Improve Azure Landing Zone README introduction --- AzureLandingZoneforNonprofits/README.md | 27 ++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/AzureLandingZoneforNonprofits/README.md b/AzureLandingZoneforNonprofits/README.md index 7af93ae2..a8b1f0b7 100644 --- a/AzureLandingZoneforNonprofits/README.md +++ b/AzureLandingZoneforNonprofits/README.md @@ -1,6 +1,27 @@ -# Azure Landing Zone CLI Installer +# Azure Landing Zone for Nonprofits -Use this package to deploy Azure Landing Zone Foundation or Expanded Platform scenarios from a command line. The package is self-contained; keep the folder structure intact when copying or extracting it. +Azure Landing Zone for Nonprofits helps nonprofit organizations set up a secure Azure foundation for cloud workloads. It provides an Azure-native baseline for management, identity and access, governance, security, monitoring, cost controls, and networking. + +Azure landing zones are the recommended Azure foundation described by the Microsoft Cloud Adoption Framework. This nonprofit package applies those concepts to practical, cost-conscious deployment choices for smaller organizations. It helps teams prepare a governed Azure environment for current workloads and future workloads, including AI-enabled workloads. It doesn't deploy application workloads. + +Azure Landing Zone for Nonprofits supports two deployment paths: + +- Foundation: a compact baseline in one existing subscription, with fewer upfront decisions and optional simple networking. +- Expanded Platform: a platform baseline for existing management and connectivity subscriptions, with stronger separation of duties and a dedicated hub network. + +## Documentation + +Review the Microsoft Learn documentation before you run this installer: + +- [Azure Landing Zone for Nonprofits overview](https://learn.microsoft.com/industry/nonprofit/azure-landing-zone-overview) +- [Plan and prepare to deploy Azure Landing Zone for Nonprofits](https://learn.microsoft.com/industry/nonprofit/azure-landing-zone-prerequisites) +- [Deploy Azure Landing Zone for Nonprofits with the Azure CLI](https://learn.microsoft.com/industry/nonprofit/azure-landing-zone-cli) +- [Post-deployment tasks for Azure Landing Zone for Nonprofits](https://learn.microsoft.com/industry/nonprofit/azure-landing-zone-post-deployment) +- [What is an Azure landing zone?](https://learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/) + +## CLI Installer Package + +Use this package to deploy Azure Landing Zone for Nonprofits Foundation or Expanded Platform scenarios from a command line. This package is intended for experienced Azure operators, automation owners, and delivery partners who need repeatable CLI deployment. The package is self-contained; keep the folder structure intact when copying or extracting it. ## What's Included @@ -18,7 +39,7 @@ Use this package to deploy Azure Landing Zone Foundation or Expanded Platform sc - An authenticated Azure CLI session with access to the target tenant and subscriptions. - Sufficient Azure permissions for the selected scenario and deployment scope. -## Validate A Deployment +## Validate a Deployment Run the following command from the package root: From 12129ba68e9795cc1562325928c509eb407fc898 Mon Sep 17 00:00:00 2001 From: Lukas Domin Date: Wed, 20 May 2026 14:16:23 +0200 Subject: [PATCH 3/3] Align Azure Landing Zone CLI output folder paths --- .../Install-AzureLandingZone.ps1 | 25 +++++++++++++++++-- .../expanded-platform.install-config.json | 2 +- .../commands/foundation.install-config.json | 2 +- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 b/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 index 32d1f448..4cb942d8 100644 --- a/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 +++ b/AzureLandingZoneforNonprofits/Install-AzureLandingZone.ps1 @@ -138,6 +138,27 @@ function Resolve-PathAgainstRoot { return [System.IO.Path]::GetFullPath((Join-Path $script:AlzRoot $PathValue)) } +function Resolve-OutputFolderPath { + param( + [Parameter(Mandatory = $true)] + [string]$PathValue, + + [Parameter(Mandatory = $true)] + [string]$ConfigDirectory + ) + + if ([System.IO.Path]::IsPathRooted($PathValue)) { + return [System.IO.Path]::GetFullPath($PathValue) + } + + $normalizedPath = $PathValue.Replace('\', '/') + if ($normalizedPath.StartsWith('./', [System.StringComparison]::Ordinal) -or $normalizedPath.StartsWith('../', [System.StringComparison]::Ordinal)) { + return [System.IO.Path]::GetFullPath((Join-Path $ConfigDirectory $PathValue)) + } + + return [System.IO.Path]::GetFullPath((Join-Path $script:AlzRoot $PathValue)) +} + function Format-CommandLine { param( [Parameter(Mandatory = $true)] @@ -1379,7 +1400,7 @@ if (-not (Test-Path $resolvedParametersFile -PathType Leaf)) { } $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' -$defaultOutputFolder = Join-Path $script:AlzRoot ("artifacts\generated\cli\{0}-{1}" -f $scenarioDefinition.id, $timestamp) +$defaultOutputFolder = "outputs/{0}-{1}" -f $scenarioDefinition.id, $timestamp $outputFolderSetting = if (-not [string]::IsNullOrWhiteSpace($OutputFolder)) { $OutputFolder } @@ -1390,7 +1411,7 @@ else { $defaultOutputFolder } -$resolvedOutputFolder = Resolve-PathAgainstRoot -PathValue $outputFolderSetting -ConfigDirectory $configDirectory +$resolvedOutputFolder = Resolve-OutputFolderPath -PathValue $outputFolderSetting -ConfigDirectory $configDirectory New-Item -Path $resolvedOutputFolder -ItemType Directory -Force | Out-Null $script:LogFile = Join-Path $resolvedOutputFolder 'installer.log' diff --git a/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json b/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json index 9d971321..4691d481 100644 --- a/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json +++ b/AzureLandingZoneforNonprofits/examples/commands/expanded-platform.install-config.json @@ -4,7 +4,7 @@ "deploymentLocation": "eastus", "parametersFile": "../parameters/expanded-platform/expanded-platform.parameters.json", "validationLevel": "Provider", - "outputFolder": "artifacts/generated/cli/expanded-platform", + "outputFolder": "outputs/expanded-platform", "nonInteractive": false, "autoApprove": false, "parameterOverrides": { diff --git a/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json b/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json index 19c11ef7..f8727c24 100644 --- a/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json +++ b/AzureLandingZoneforNonprofits/examples/commands/foundation.install-config.json @@ -7,7 +7,7 @@ "subscription": "" }, "validationLevel": "Provider", - "outputFolder": "artifacts/generated/cli/foundation", + "outputFolder": "outputs/foundation", "nonInteractive": false, "autoApprove": false, "parameterOverrides": {