Skip to content

Commit efe1b74

Browse files
committed
Updating src deployment to handle policy rules
1 parent 6ec2188 commit efe1b74

6 files changed

Lines changed: 143 additions & 101 deletions

File tree

tests/integration/all-resource-types/Deploy-SourceApim.ps1

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ $ErrorActionPreference = 'Stop'
5656
$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' }
5757
$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' }
5858
Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') -Force
59+
Import-Module (Join-Path $PSScriptRoot 'DeploymentHelpers.psm1') -Force
5960

6061
# Map this script's LogLevel (Info/Verbose/Debug) to the apiops CLI log level
6162
# values used in the printed example command.
@@ -104,7 +105,8 @@ $requiredProviders = @(
104105
'Microsoft.Insights',
105106
'Microsoft.OperationalInsights',
106107
'Microsoft.EventHub',
107-
'Microsoft.KeyVault'
108+
'Microsoft.KeyVault',
109+
'Microsoft.AlertsManagement'
108110
)
109111
foreach ($provider in $requiredProviders) {
110112
$state = az provider show --namespace $provider --query "registrationState" --output tsv 2>$null
@@ -175,15 +177,44 @@ $azArgs = @(
175177

176178
$raw = Invoke-MaskedAzCommand -Replacements $azReplacements -Arguments $azArgs
177179

178-
$result = $raw | ConvertFrom-Json
179-
180180
if ($LASTEXITCODE -ne 0) {
181-
Write-Error "Deployment failed. Check the Azure portal for details."
181+
Write-DeploymentFailureDetails `
182+
-ResourceGroupName $ResourceGroupName `
183+
-DeploymentName $deploymentName `
184+
-Replacements $azReplacements
185+
throw "Source APIM deployment failed (deployment '$deploymentName' in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'). See failed-operation details above."
182186
}
183187

188+
$result = $raw | ConvertFrom-Json
189+
184190
# Extract outputs
185191
$outputs = $result.properties.outputs
186192

193+
# policyRestriction is created here (not in Bicep) because scope validation needs the APIM data-plane to be active.
194+
$isClassicSku = $SkuName -in @('Developer', 'Premium')
195+
if ($isClassicSku) {
196+
$apimServiceName = $outputs.apimServiceName.value
197+
Wait-ApimActivation -ResourceGroupName $ResourceGroupName -ApimName $apimServiceName | Out-Null
198+
199+
$restrictionName = 'src-restriction-ip'
200+
$productId = 'src-product-starter'
201+
$restrictionUrl = "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.ApiManagement/service/$apimServiceName/policyRestrictions/$restrictionName?api-version=2024-05-01"
202+
$restrictionBody = (@{ properties = @{ scope = "/products/$productId"; requireBase = 'true' } } | ConvertTo-Json -Compress)
203+
204+
Write-Host "Creating policyRestriction '$restrictionName' (scope /products/$productId)..." -ForegroundColor Cyan
205+
$putReplacements = $azReplacements.Clone()
206+
$putReplacements[$apimServiceName] = Protect-ApimName -Value $apimServiceName
207+
Invoke-MaskedProcess -FilePath 'az' -Replacements $putReplacements -Arguments @(
208+
'rest', '--method', 'put',
209+
'--url', $restrictionUrl,
210+
'--body', $restrictionBody
211+
)
212+
if ($LASTEXITCODE -ne 0) {
213+
throw "Failed to create policyRestriction '$restrictionName' on APIM '$(Protect-ApimName -Value $apimServiceName)'"
214+
}
215+
Write-Host " policyRestriction created" -ForegroundColor Green
216+
}
217+
187218
Write-Host ""
188219
Write-Host "============================================================" -ForegroundColor Green
189220
Write-Host "✅ Kitchen Sink APIM deployed successfully!" -ForegroundColor Green
@@ -194,7 +225,7 @@ Write-Host ""
194225
Write-Host " npx apiops extract \"
195226
Write-Host " --subscription-id $(Protect-SubscriptionId -Value $outputs.subscriptionId.value) \"
196227
Write-Host " --resource-group $(Protect-ResourceGroupName -Value $outputs.resourceGroupName.value) \"
197-
Write-Host " --service-name $($outputs.apimServiceName.value) \"
228+
Write-Host " --service-name $(Protect-ApimName -Value $outputs.apimServiceName.value) \"
198229
Write-Host " --output-dir ./extracted \"
199230
Write-Host " --log-level $apiopsLogLevel"
200231
Write-Host ""

tests/integration/all-resource-types/Deploy-TargetApim.ps1

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ $ErrorActionPreference = 'Stop'
4040
$VerbosePreference = if ($LogLevel -in @('Verbose', 'Debug')) { 'Continue' } else { 'SilentlyContinue' }
4141
$DebugPreference = if ($LogLevel -eq 'Debug') { 'Continue' } else { 'SilentlyContinue' }
4242
Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') -Force
43+
Import-Module (Join-Path $PSScriptRoot 'DeploymentHelpers.psm1') -Force
4344

4445
$bicepFile = Join-Path $PSScriptRoot 'target-apim.bicep'
4546

@@ -79,10 +80,12 @@ $azReplacements = @{
7980
$ResourceGroupName = Protect-ResourceGroupName -Value $ResourceGroupName
8081
}
8182

83+
$deploymentName = "target-apim-$(Get-Date -Format 'yyyyMMddHHmmss')"
84+
8285
$azArgs = @(
8386
'deployment', 'group', 'create',
8487
'--resource-group', $ResourceGroupName,
85-
'--name', "target-apim-$(Get-Date -Format 'yyyyMMddHHmmss')",
88+
'--name', $deploymentName,
8689
'--template-file', $bicepFile,
8790
'--parameters', "skuName=$SkuName", "location=$Location", "publisherEmail=$PublisherEmail",
8891
'--output', 'json'
@@ -91,14 +94,18 @@ $azArgs = @(
9194
$raw = Invoke-MaskedAzCommand -Replacements $azReplacements -Arguments $azArgs
9295

9396
if ($LASTEXITCODE -ne 0) {
94-
throw "Target APIM deployment failed"
97+
Write-DeploymentFailureDetails `
98+
-ResourceGroupName $ResourceGroupName `
99+
-DeploymentName $deploymentName `
100+
-Replacements $azReplacements
101+
throw "Target APIM deployment failed (deployment '$deploymentName' in resource group '$(Protect-ResourceGroupName -Value $ResourceGroupName)'). See failed-operation details above."
95102
}
96103

97104
$result = $raw | ConvertFrom-Json
98105
if (-not $result.properties.outputs) {
99106
throw "Target deployment returned no outputs"
100107
}
101108

102-
Write-Host "✅ Target APIM deployed successfully: $($result.properties.outputs.apimServiceName.value)"
109+
Write-Host "✅ Target APIM deployed successfully: $(Protect-ApimName -Value $result.properties.outputs.apimServiceName.value)"
103110

104111
return $result.properties.outputs
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1')
2+
3+
function Write-DeploymentFailureDetails {
4+
[CmdletBinding()]
5+
param(
6+
[Parameter(Mandatory)][string]$ResourceGroupName,
7+
[Parameter(Mandatory)][string]$DeploymentName,
8+
[hashtable]$Replacements = @{}
9+
)
10+
11+
$maskedRg = Protect-ResourceGroupName -Value $ResourceGroupName
12+
Write-Host ""
13+
Write-Host "========== Deployment failure details ==========" -ForegroundColor Yellow
14+
Write-Host "Resource group: $maskedRg" -ForegroundColor Yellow
15+
Write-Host "Deployment: $DeploymentName" -ForegroundColor Yellow
16+
Write-Host "Querying failed deployment operations (before teardown)..." -ForegroundColor Yellow
17+
18+
$query = "[?properties.provisioningState=='Failed'].{resource:properties.targetResource.resourceName, type:properties.targetResource.resourceType, code:properties.statusMessage.error.code, message:properties.statusMessage.error.message, details:properties.statusMessage.error.details}"
19+
20+
try {
21+
Invoke-MaskedProcess -FilePath 'az' -Replacements $Replacements -Arguments @(
22+
'deployment', 'operation', 'group', 'list',
23+
'--resource-group', $ResourceGroupName,
24+
'--name', $DeploymentName,
25+
'--query', $query,
26+
'--output', 'json'
27+
)
28+
} catch {
29+
$maskedErr = Protect-LogLine -Line ($_.Exception.Message) -Replacements $Replacements
30+
Write-Host "Failed to retrieve deployment operations: $maskedErr" -ForegroundColor Red
31+
}
32+
33+
Write-Host "================================================" -ForegroundColor Yellow
34+
}
35+
36+
function Wait-ApimActivation {
37+
[CmdletBinding()]
38+
param(
39+
[Parameter(Mandatory)][string]$ResourceGroupName,
40+
[Parameter(Mandatory)][string]$ApimName,
41+
[int]$TimeoutSeconds = 1800,
42+
[int]$PollIntervalSeconds = 20
43+
)
44+
45+
$maskedApim = Protect-ApimName -Value $ApimName
46+
Write-Host "Waiting for APIM '$maskedApim' to finish Activating (timeout ${TimeoutSeconds}s)..." -ForegroundColor Cyan
47+
48+
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
49+
$lastState = $null
50+
while ((Get-Date) -lt $deadline) {
51+
$state = az apim show --resource-group $ResourceGroupName --name $ApimName --query provisioningState --output tsv 2>$null
52+
if ($state -ne $lastState) {
53+
Write-Host " provisioningState: $state" -ForegroundColor Gray
54+
$lastState = $state
55+
}
56+
if ($state -eq 'Succeeded') { return $true }
57+
if ($state -eq 'Failed') { throw "APIM '$maskedApim' entered Failed state during activation wait" }
58+
Start-Sleep -Seconds $PollIntervalSeconds
59+
}
60+
throw "APIM '$maskedApim' did not reach Succeeded within ${TimeoutSeconds}s (last state: $lastState)"
61+
}
62+
63+
Export-ModuleMember -Function `
64+
Write-DeploymentFailureDetails, `
65+
Wait-ApimActivation

tests/integration/all-resource-types/MaskingHelpers.psm1

Lines changed: 17 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,31 @@
1-
# MaskingHelpers — secret-redaction utilities for the round-trip integration
2-
# test scripts. Imported via:
3-
#
4-
# Import-Module (Join-Path $PSScriptRoot 'MaskingHelpers.psm1') -Force
5-
#
6-
7-
# Module-scoped flag. Flip to $false locally if you need to see raw values
8-
# while debugging — never commit that change.
1+
# MaskingHelpers — secret-redaction utilities for the round-trip integration test scripts.
2+
93
$script:EnableMasking = $true
104

11-
# Built-in regex redactions applied by Protect-LogLine on every line, in
12-
# addition to caller-supplied exact-match Replacements. These cover sensitive
13-
# values that the caller cannot enumerate up front (ARM async-operation
14-
# signing material, request IDs, etc.).
15-
#
16-
# IMPORTANT: keep these patterns conservative. We deliberately do NOT mask
17-
# every GUID, because well-known role-definition IDs and template hashes are
18-
# legitimately public and useful in debugging. Anchor patterns to the path
19-
# segment, header name, or query-parameter context that makes the value
20-
# sensitive.
21-
#
22-
# References:
23-
# - ARM async operations contract (see `Azure-AsyncOperation` /
24-
# `operationStatuses` URL shape with t/c/s/h query parameters):
25-
# https://learn.microsoft.com/azure/azure-resource-manager/management/async-operations
26-
# - Azure correlation / request IDs (`x-ms-correlation-request-id`,
27-
# `x-ms-request-id`, `x-ms-client-request-id`):
28-
# https://learn.microsoft.com/azure/azure-resource-manager/management/request-limits-and-throttling
295
$script:BuiltinRedactions = @(
30-
# ARM async-operation signing material: t= (timestamp), c= (cert),
31-
# s= (signature), h= (hash) on operationStatuses / operationResults URLs.
32-
# These query parameters are effectively a bearer credential for polling
33-
# the long-running operation result and MUST NOT appear in logs.
346
@{ Pattern = '([?&])(t|c|s|h)=[^&''"\s]+'
357
Replacement = '$1$2=<REDACTED:arm-async>' }
368

37-
# Azure subscription IDs embedded in ARM resource paths.
389
@{ Pattern = '/subscriptions/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}'
39-
Replacement = '/subscriptions/<SUBSCRIPTION_ID>' }
10+
Replacement = '/subscriptions/<REDACTED:subscription-id>' }
4011

41-
# ARM long-running operation IDs (numeric or alphanumeric, 10+ chars)
42-
# following /operationStatuses/ or /operationResults/.
4312
@{ Pattern = '/(operationStatuses|operationResults)/[A-Za-z0-9._-]{10,}'
44-
Replacement = '/$1/<OPERATION_ID>' }
13+
Replacement = '/$1/<REDACTED:operation-id>' }
4514

46-
# x-ms-correlation-request-id, x-ms-request-id, x-ms-client-request-id
47-
# header values. Match Python-repr style (`'x-ms-request-id': 'guid'`),
48-
# HTTP-header style (`x-ms-request-id: guid`), and JSON style.
4915
@{ Pattern = "(?i)(['""]?x-ms-(?:correlation-)?(?:request|client-request)-id['""]?\s*[:=]\s*['""]?)[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
5016
Replacement = '$1<REDACTED:request-id>' }
5117

52-
# x-ms-routing-request-id: '<REGION>:<TIMESTAMP>:<guid>'. Mask the whole
53-
# value because all three components (region, timestamp, GUID) leak
54-
# tenant geography / activity timing.
5518
@{ Pattern = "(?i)(['""]?x-ms-routing-request-id['""]?\s*[:=]\s*['""]?)[A-Z0-9]+:\d{8}T\d{6}Z:[0-9a-fA-F-]{36}"
5619
Replacement = '$1<REDACTED:routing-request-id>' }
5720

58-
# Authorization: Bearer <token> — defense in depth. We do not expect
59-
# bearer tokens to appear because `az --debug` redacts them by default,
60-
# but if Azure CLI ever changes that, this catches them.
6121
@{ Pattern = "(?i)(authorization[:\s=]+bearer\s+)[A-Za-z0-9._\-+/=]+"
6222
Replacement = '$1<REDACTED:bearer>' }
6323

64-
# JWT-shaped tokens (eyJ<base64url>.<base64url>.<base64url>).
6524
@{ Pattern = '\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}'
6625
Replacement = '<REDACTED:jwt>' }
26+
27+
@{ Pattern = '[A-Za-z0-9](?:[A-Za-z0-9._%+\-]*[A-Za-z0-9])?@[A-Za-z0-9](?:[A-Za-z0-9.\-]*[A-Za-z0-9])?\.[A-Za-z]{2,}'
28+
Replacement = '<REDACTED:email>' }
6729
)
6830

6931
function Protect-Identifier {
@@ -78,29 +40,32 @@ function Protect-Identifier {
7840
}
7941

8042
if ([string]::IsNullOrWhiteSpace($Value)) {
81-
return '<empty>'
43+
return '<REDACTED:empty>'
8244
}
8345

8446
if ($Value.Length -le ($Prefix + $Suffix)) {
85-
return ('*' * $Value.Length)
47+
return '<REDACTED>'
8648
}
8749

8850
return "{0}...{1}" -f $Value.Substring(0, $Prefix), $Value.Substring($Value.Length - $Suffix)
8951
}
9052

9153
function Protect-SubscriptionId {
9254
param([string]$Value)
93-
return Protect-Identifier -Value $Value -Prefix 3 -Suffix 0
55+
if (-not $script:EnableMasking) { return $Value }
56+
return '<REDACTED:subscription-id>'
9457
}
9558

96-
# Resource group names follow the convention `bvt-<timestamp>-<rand>-(src|tgt)-rg`.
97-
# Reveal the last 7 characters so the role suffix (`-src-rg` / `-tgt-rg`) is
98-
# visible in logs while the unique id stays masked.
9959
function Protect-ResourceGroupName {
10060
param([string]$Value)
10161
return Protect-Identifier -Value $Value -Prefix 3 -Suffix 7
10262
}
10363

64+
function Protect-ApimName {
65+
param([string]$Value)
66+
return Protect-Identifier -Value $Value -Prefix 3 -Suffix 8
67+
}
68+
10469
function Protect-LogLine {
10570
param(
10671
[string]$Line,
@@ -113,7 +78,6 @@ function Protect-LogLine {
11378

11479
$protectedLine = $Line
11580

116-
# Pass 1: caller-supplied exact-string replacements (sub id, RG name, ...).
11781
if ($Replacements) {
11882
foreach ($entry in $Replacements.GetEnumerator()) {
11983
if ([string]::IsNullOrEmpty($entry.Key) -or [string]::IsNullOrEmpty($entry.Value)) {
@@ -124,9 +88,6 @@ function Protect-LogLine {
12488
}
12589
}
12690

127-
# Pass 2: built-in regex redactions for well-known secret shapes that
128-
# callers cannot enumerate up front (ARM signing material, request IDs,
129-
# operation IDs, bearer tokens). Always applied.
13091
foreach ($rule in $script:BuiltinRedactions) {
13192
$protectedLine = [System.Text.RegularExpressions.Regex]::Replace(
13293
$protectedLine,
@@ -137,11 +98,6 @@ function Protect-LogLine {
13798
return $protectedLine
13899
}
139100

140-
# Native-process invocation with masked output. Uses System.Diagnostics.Process
141-
# with redirected stdout/stderr so the child's raw bytes bypass PowerShell's
142-
# ErrorRecord promotion (which would otherwise leak unmasked to Start-Transcript).
143-
144-
# Windows npm shims (e.g. npx.cmd) need cmd.exe; *nix shebang scripts launch directly.
145101
function Resolve-NativeExecutable {
146102
param([string]$Name)
147103

@@ -159,8 +115,6 @@ function Resolve-NativeExecutable {
159115
return [pscustomobject]@{ FilePath = $exePath; Prefix = $prefix }
160116
}
161117

162-
# Stderr is always masked + streamed. Stdout is either captured (and returned)
163-
# or masked + streamed. Sets $global:LASTEXITCODE.
164118
function Invoke-MaskedProcess {
165119
[CmdletBinding()]
166120
param(
@@ -180,7 +134,6 @@ function Invoke-MaskedProcess {
180134
$psi.RedirectStandardInput = $false
181135
$psi.UseShellExecute = $false
182136
$psi.CreateNoWindow = $true
183-
# Default OEM encoding on Windows mangles `az --debug` output.
184137
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
185138
$psi.StandardErrorEncoding = [System.Text.Encoding]::UTF8
186139

@@ -194,15 +147,11 @@ function Invoke-MaskedProcess {
194147
$proc = [System.Diagnostics.Process]::new()
195148
$proc.StartInfo = $psi
196149

197-
# Allocate explicitly — `$x = if (...) { [List[T]]::new() }` returns $null
198-
# because PowerShell enumerates the empty list.
199150
$stdoutBuffer = $null
200151
if ($CaptureStdout) {
201152
$stdoutBuffer = [System.Collections.Generic.List[string]]::new()
202153
}
203154

204-
# Use ThreadJob readers rather than Register-ObjectEvent: Action handlers
205-
# don't drain reliably while the main runspace is in Start-Sleep.
206155
$readerScript = {
207156
param([System.IO.StreamReader]$Reader,
208157
[System.Collections.Concurrent.ConcurrentQueue[string]]$Queue)
@@ -241,7 +190,6 @@ function Invoke-MaskedProcess {
241190
}
242191
}
243192

244-
# Readers exit on EOF once the child closes its pipes.
245193
Wait-Job -Job $outJob, $errJob | Out-Null
246194

247195
while ($stderrQueue.TryDequeue([ref]$line)) {
@@ -268,7 +216,6 @@ function Invoke-MaskedProcess {
268216
}
269217
}
270218

271-
# Run `npx apiops <args>` masking ALL output streams. Returns the exit code.
272219
function Invoke-MaskedApiopsCommand {
273220
[CmdletBinding()]
274221
param(
@@ -283,9 +230,6 @@ function Invoke-MaskedApiopsCommand {
283230
return $LASTEXITCODE
284231
}
285232

286-
# Run `az <args>` capturing stdout (typically JSON) for the caller while
287-
# masking and streaming stderr (verbose/debug output). Returns stdout as a
288-
# single string. Sets/preserves $LASTEXITCODE for the caller.
289233
function Invoke-MaskedAzCommand {
290234
[CmdletBinding()]
291235
param(
@@ -303,6 +247,7 @@ Export-ModuleMember -Function `
303247
Protect-Identifier, `
304248
Protect-SubscriptionId, `
305249
Protect-ResourceGroupName, `
250+
Protect-ApimName, `
306251
Protect-LogLine, `
307252
Resolve-NativeExecutable, `
308253
Invoke-MaskedProcess, `

0 commit comments

Comments
 (0)