Skip to content

Commit 3965331

Browse files
committed
fix(infra): trust snet-app on storage networkAcls + replace stub PDFs
The Spring Boot API was returning 500 on PDF download in the deployed environment because every ADLS Gen2 read failed with HTTP 403 AuthorizationFailure. Root cause: App Service regional VNet integration traffic was not being trusted by the storage account network rules even though a Private Endpoint for the dfs sub-resource existed in snet-pe and DNS resolved to it correctly. Empirically the Private Endpoint alone is not sufficient for traffic that originates from the App Service VNet integration's edge agent (sockets show a 169.254/16 link-local source), so storage rejected every request when defaultAction=Deny. Fix: - snet-app now has a Microsoft.Storage service endpoint. - The storage account networkAcls always allows snet-app via a VirtualNetworkRule; publicNetworkAccess stays Enabled (required for VirtualNetworkRules to take effect) but defaultAction=Deny + the VNet rule provide the equivalent restriction. - main.bicep wires vnet.outputs.appSubnetId into the storage module. - deploy.ps1 doc comments updated to describe the new posture; Step 10 message no longer references a Private Endpoint that the app does not actually traverse. Also fixes the PDF seed data: the original five files were hand-rolled single-line stubs (~568 bytes each) that rendered as a near-empty page. A new scripts/generate-sample-evidence.ps1 produces dependency-free multi-page PDFs (~5-7 KB each) with synthetic but realistic narrative content per evidence type (witness statement, forensic report, chain of custody, surveillance log, contract agreement). The script is idempotent and can be re-run any time. A repo-level .gitattributes is added so PDFs and other binary assets are checked out byte-exact on Windows clones (Git was about to rewrite LF -> CRLF inside the PDF byte stream and corrupt the xref table).
1 parent ac52276 commit 3965331

11 files changed

Lines changed: 849 additions & 21 deletions

File tree

.gitattributes

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# ---------------------------------------------------------------------------
2+
# .gitattributes
3+
#
4+
# Force binary handling for file types that must NOT be touched by Git's
5+
# line-ending normalisation. Without this, the PDF seed evidence files
6+
# under sample-app/api/src/main/resources/data/sample-evidence/ get
7+
# corrupted on Windows checkouts because Git rewrites LF -> CRLF inside
8+
# the byte stream, breaking the PDF cross-reference table.
9+
# ---------------------------------------------------------------------------
10+
11+
# Documents
12+
*.pdf binary
13+
*.doc binary
14+
*.docx binary
15+
*.xls binary
16+
*.xlsx binary
17+
*.ppt binary
18+
*.pptx binary
19+
20+
# Images
21+
*.png binary
22+
*.jpg binary
23+
*.jpeg binary
24+
*.gif binary
25+
*.ico binary
26+
*.webp binary
27+
28+
# Archives
29+
*.zip binary
30+
*.gz binary
31+
*.tar binary
32+
*.7z binary
33+
*.jar binary
34+
*.war binary
35+
36+
# Fonts
37+
*.ttf binary
38+
*.otf binary
39+
*.woff binary
40+
*.woff2 binary
41+
42+
# Media
43+
*.mp3 binary
44+
*.mp4 binary
45+
*.mov binary
46+
*.wav binary

infra/main.bicep

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ module storageAccount 'modules/storage-account.bicep' = {
100100
location: location
101101
tags: tags
102102
deployerIp: deployerIp
103+
appSubnetId: vnet.outputs.appSubnetId
103104
}
104105
}
105106

infra/modules/storage-account.bicep

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,23 @@
44
// Posture:
55
// * isHnsEnabled = true → Azure Data Lake Storage Gen2.
66
// * allowSharedKeyAccess = false → only Entra ID (OAuth + RBAC) auth.
7-
// * publicNetworkAccess = Disabled → reachable only via Private Endpoint.
7+
// * publicNetworkAccess = Enabled → required for VirtualNetworkRules to
8+
// take effect (Disabled would cause
9+
// storage to reject every request
10+
// that doesn't traverse a Private
11+
// Endpoint, including snet-app
12+
// traffic that arrives via the
13+
// regional VNet integration).
814
// * networkAcls.defaultAction = Deny.
15+
// * virtualNetworkRules → snet-app is always allowed
16+
// (App Service VNet integration).
17+
// * ipRules → optional deployer IP for seeding.
918
// * allowBlobPublicAccess = false → no anonymous access.
1019
//
1120
// Optional: a single deployer IP can be temporarily added to networkAcls.
1221
// ipRules so seeding scripts can upload sample evidence over OAuth before
13-
// the App Service starts using the private endpoint. Pass an empty string
14-
// to omit.
22+
// the App Service starts serving real traffic. Pass an empty string to
23+
// remove it.
1524
// ---------------------------------------------------------------------------
1625

1726
@description('Globally unique storage account name (3-24 lowercase alphanumeric).')
@@ -25,6 +34,9 @@ param location string
2534
@description('Resource tags.')
2635
param tags object = {}
2736

37+
@description('Resource ID of the App Service VNet-integration subnet (snet-app). The subnet must have a Microsoft.Storage service endpoint enabled.')
38+
param appSubnetId string
39+
2840
@description('Optional public IP (or CIDR) of the deployer to temporarily allow over Entra ID auth (e.g. for sample-evidence seeding). Leave empty to keep the account fully private.')
2941
param deployerIp string = ''
3042

@@ -45,7 +57,10 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
4557
allowBlobPublicAccess: false
4658
allowSharedKeyAccess: false
4759
defaultToOAuthAuthentication: true
48-
publicNetworkAccess: hasDeployerIp ? 'Enabled' : 'Disabled'
60+
// VirtualNetworkRules require publicNetworkAccess = Enabled.
61+
// defaultAction = Deny still rejects everything that doesn't match
62+
// a network rule.
63+
publicNetworkAccess: 'Enabled'
4964
networkAcls: {
5065
bypass: 'AzureServices'
5166
defaultAction: 'Deny'
@@ -55,7 +70,12 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
5570
action: 'Allow'
5671
}
5772
] : []
58-
virtualNetworkRules: []
73+
virtualNetworkRules: [
74+
{
75+
id: appSubnetId
76+
action: 'Allow'
77+
}
78+
]
5979
}
6080
}
6181
}

infra/modules/vnet.bicep

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,19 @@ resource vnet 'Microsoft.Network/virtualNetworks@2023-11-01' = {
4747
}
4848
}
4949
]
50-
// Allow App Service to reach the storage private endpoint
51-
serviceEndpoints: []
50+
// Microsoft.Storage service endpoint is REQUIRED for the App
51+
// Service VNet integration to be trusted by the storage account
52+
// network rules. Empirically, traffic from the regional VNet
53+
// integration does not bypass storage networkAcls via the
54+
// Private Endpoint alone — the storage account must explicitly
55+
// allow the snet-app subnet via a VirtualNetworkRule, which
56+
// only works when this service endpoint is present.
57+
serviceEndpoints: [
58+
{
59+
service: 'Microsoft.Storage'
60+
locations: [ location ]
61+
}
62+
]
5263
privateEndpointNetworkPolicies: 'Enabled'
5364
privateLinkServiceNetworkPolicies: 'Enabled'
5465
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

scripts/deploy.ps1

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
3. Generates a globally-unique storage account name.
1313
4. Creates the resource group if needed.
1414
5. Detects the deployer's public IP + Entra principal objectId.
15-
6. Deploys infra (VNet with snet-app + snet-pe, App Service Plan,
16-
two App Services with Regional VNet integration, hardened ADLS
17-
Gen2 storage with shared keys disabled and publicNetworkAccess
18-
off, Private Endpoint on the dfs sub-resource, Private DNS Zone
19-
privatelink.dfs.<storage suffix>, App Insights, MI role
20-
assignments) via Bicep. The deployer IP is added to storage
21-
networkAcls only for the duration of the seeding step.
15+
7. Deploys infra (VNet with snet-app + snet-pe, snet-app has a
16+
Microsoft.Storage service endpoint, App Service Plan, two App
17+
Services with Regional VNet integration, hardened ADLS Gen2
18+
storage with shared keys disabled, defaultAction=Deny + a
19+
VirtualNetworkRule allowing snet-app, Private Endpoint on the
20+
dfs sub-resource, Private DNS Zone privatelink.dfs.<storage
21+
suffix>, App Insights, MI role assignments) via Bicep. The
22+
deployer IP is added to storage networkAcls only for the
23+
duration of the seeding step.
2224
7. Patches environment.prod.ts with the deployed App Service URLs +
2325
App Insights connection string + tenant/client IDs.
2426
8. Builds the Angular SPA (production) and packages the Spring Boot API.
@@ -27,9 +29,10 @@
2729
SPA app registration (via Graph).
2830
11. Uploads the bundled sample evidence PDFs over OAuth (Storage Blob
2931
Data Contributor RBAC, no shared keys) to the storage container.
30-
12. Re-deploys the storage module with deployerIp='' to flip
31-
publicNetworkAccess back to Disabled (App Services keep working
32-
via the Private Endpoint).
32+
12. Re-deploys the storage module with deployerIp='' to remove the
33+
deployer's IP from networkAcls (App Services keep working via
34+
the snet-app VirtualNetworkRule + Microsoft.Storage service
35+
endpoint).
3336
13. Runs smoke verification: API /api/cases responds, SPA returns 200.
3437
3538
All steps are idempotent and safe to re-run.
@@ -402,9 +405,12 @@ if (-not $SkipUpload) {
402405
# ---------------------------------------------------------------------------
403406
Write-Section 'Step 10: Locking down storage (removing temporary deployer IP)'
404407

405-
# Re-deploy with deployerIp='' so storage publicNetworkAccess flips back to
406-
# Disabled. The App Services keep working because they reach storage via
407-
# the Private Endpoint inside the integrated VNet.
408+
# Re-deploy with deployerIp='' so the seeding allow-rule is removed.
409+
# The App Services keep working because storage networkAcls trust snet-app
410+
# via a VirtualNetworkRule + the Microsoft.Storage service endpoint on the
411+
# subnet (publicNetworkAccess stays Enabled by design — Disabled would
412+
# block VNet rules; defaultAction=Deny + the VNet rule provide the
413+
# equivalent restriction).
408414
az deployment group create `
409415
--resource-group $ResourceGroup `
410416
--template-file "$RepoRoot/infra/main.bicep" `
@@ -419,7 +425,7 @@ az deployment group create `
419425
deployerPrincipalId=$deployerObjectId `
420426
deployerPrincipalType=$deployerSpType `
421427
--output none
422-
Write-Host ' Storage networkAcls now reject all public traffic. App Services reach storage via Private Endpoint.'
428+
Write-Host ' Storage networkAcls trust snet-app only. Public traffic (other than App Services via VNet integration) is denied.'
423429

424430
# ---------------------------------------------------------------------------
425431
# Step 11 — Smoke verification

0 commit comments

Comments
 (0)