1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467 | // ── Mike Azure Stack — main entry point ─────────────────────────────────────
//
// Deploy:
// az deployment group create \
// --resource-group rg-mike-<env> \
// --template-file infra/main.bicep \
// --parameters infra/main.parameters.<env>.json
//
// Modules are added incrementally as infrastructure slices are completed:
// 001 — network, keyvault
// 002 — acr
// 003 — postgres
// 005 — storage
// 006 — managed-identity, containerapps-env, containerapp-postgrest,
// containerapp-backend, containerapp-job-migrate
// ── Parameters ───────────────────────────────────────────────────────────────
@description('Environment suffix used in all resource names (dev, prod)')
param env string
@description('Azure region for all resources. Defaults to the resource group location so the marketplace wizard does not pin a specific region (TTK rule "Location Should Not Be Hardcoded").')
param location string = resourceGroup().location
// ── PostgreSQL ────────────────────────────────────────────────────────────────
@description('PostgreSQL Flexible Server compute SKU. The allowed-values list mirrors the createUiDefinition DropDown so TTK\'s "Allowed Values Should Actually Be Allowed" rule passes — the rule requires the main template to declare every option the wizard can emit.')
@allowed([
{ name: 'Standard_B2s', tier: 'Burstable' }
{ name: 'Standard_B1ms', tier: 'Burstable' }
{ name: 'Standard_D2ds_v5', tier: 'GeneralPurpose' }
])
param pgSku object = { name: 'Standard_B2s', tier: 'Burstable' }
@description('PostgreSQL initial storage in GB')
param pgStorageGb int = 32
@description('PostgreSQL backup retention in days (max 35 on Burstable)')
param pgBackupRetentionDays int = 7
@description('Enable zone-redundant HA (requires General Purpose tier)')
param enableHa bool = false
@description('PostgreSQL admin username')
param pgAdminUser string = 'mikeadmin'
@description('PostgreSQL admin password — pass at deploy time, never store in parameter files')
@secure()
param pgAdminPassword string = ''
// ── Container images ──────────────────────────────────────────────────────────
@description('Backend container image, e.g. acrmikeoss.azurecr.io/backend:<sha>')
param backendImage string = ''
@description('PostgREST image, e.g. acrmikeoss.azurecr.io/postgrest:v12.2.3')
param postgrestImage string = 'acrmikeoss.azurecr.io/postgrest:v12.2.3'
@description('Azure Container Registry FQDN')
param containerRegistry string = 'acrmikeoss.azurecr.io'
@description('Provision a new ACR in this deployment (false = use existing containerRegistry)')
param createAcr bool = false
@description('ACR SKU when createAcr = true')
param acrSku string = 'Basic'
@description('Image pull mode. "managed-identity" (default) authenticates Container Apps against a private ACR via the user-assigned MI — used by self-hosted/dev deploys. "anonymous" leaves Container Apps registries[] empty and skips the AcrPull role grant — used by the marketplace flow, where images are pulled from a public anonymous-pull publisher ACR (acrmikeoss.azurecr.io).')
@allowed(['managed-identity', 'anonymous'])
param imagePullAuth string = 'managed-identity'
// ── Network ───────────────────────────────────────────────────────────────────
@description('Provision NAT Gateway for stable outbound IP on subnet-cae')
param enableNatGateway bool = true
// ── Frontend ──────────────────────────────────────────────────────────────────
@description('URL of the frontend app, used for CORS allow-origin + FRONTEND_URL env on the backend. Empty (default) means "derive from the Container Apps environment" — for fresh marketplace installs the buyer should leave this empty; custom domains are configured post-install via /install, not at deploy time. See docs/issues/azure-migration/040-install-configurator-ux-gripes.md Entry 9.')
param frontendUrl string = ''
@description('Blob Storage container name for document uploads')
param storageContainerName string = 'documents'
// ── Install configurator (issue 023) ─────────────────────────────────────────
@description('Object ID of the principal running the deployment. Receives Key Vault Secrets Officer for installer flow. Empty string defers to Bicep\'s deployer() built-in, which resolves to the ARM-deployment principal (marketplace buyer for marketplace installs; az-CLI signed-in user for direct deploys). Pass explicitly to override (e.g. CI pipelines that deploy under a service principal but want a human granted access).')
param deployerPrincipalId string = ''
// Resolve the effective deployer principal: explicit param wins, otherwise
// fall through to Bicep's deployer() built-in. deployer() returns the ARM-
// deployment caller's objectId without any createUiDefinition or deploy-
// script plumbing — fixes the marketplace install path where the buyer
// would otherwise have no KV data-plane access (RBAC Owner is management-
// plane only; KV secrets need a dedicated data-plane role).
// See docs/issues/azure-migration/038-install-first-visit-bootstrap.md.
var effectiveDeployerPrincipalId = empty(deployerPrincipalId) ? deployer().objectId : deployerPrincipalId
@description('Install bootstrap token. Defaults to a fresh GUID minted at deploy time. To preserve an existing token across redeploys (recommended for idempotency), the caller reads the current KV secret and passes it here. newGuid() must live in a parameter default — bicep ≥0.34 disallows it elsewhere.')
@secure()
param bootstrapToken string = newGuid()
@description('PostgREST HMAC JWT secret. Defaults to a fresh GUID minted at deploy time; pass the existing KV value on redeploys to keep PostgREST able to validate previously-issued tokens. Same newGuid()-in-default rule as bootstrapToken.')
@secure()
param postgrestJwtSecret string = newGuid()
@description('Auth provider applied to backend + PostgREST: supabase | local | entra. Defaults to entra so a fresh marketplace deploy lands in the supported production mode without operator override. See docs/issues/azure-migration/036-marketplace-install-gaps.md gap #3.')
param authProvider string = 'entra'
// entraBackendScope param removed: the scope is fully deterministic
// from the backend app reg's client id (api://<guid>/access_as_user),
// so passing it as a Bicep param invited drift. Backend's
// routes/auth.ts:entraScopes() derives it at runtime from
// ENTRA_BACKEND_CLIENT_ID. Gap #4 in 036-marketplace-install-gaps.md.
@description('Tenant onboarding mode (auto | manual). Default auto — marketplace installs auto-register the buyer\'s tenant on first sign-in. Pass "manual" for multi-tenant SaaS deployments that need explicit enrolment per tenant.')
param tenantOnboardingMode string = 'auto'
@description('Object ID of the user installing this deployment. Marketplace pipelines pass the buyer\'s oid from the SaaS handshake; deploy.ps1 can pass the result of `az ad signed-in-user show --query id -o tsv`. When non-empty, this user is granted a permanent admin escape hatch in /install — sign-in succeeds regardless of group membership. Recovery path for misconfigured-admin-group lockouts. Empty disables the escape hatch.')
param initialAdminObjectId string = ''
@description('Password for the dedicated PostgREST authenticator role. Defaults to a fresh GUID per deploy; pass an existing value (e.g. read from KV by deploy.ps1) to keep stable across redeploys. See gap #24 in 036-marketplace-install-gaps.md.')
@secure()
param pgrstAuthenticatorPassword string = newGuid()
@description('Postgres role name to use for migration MI auth (AUTH_PROVIDER=entra)')
param migrationPgMiUsername string = ''
// ── Azure Marketplace customer usage attribution ─────────────────────────────
// Partner Center requires a Microsoft.Resources/deployments resource named
// `pid-<guid>-partnercenter` declared exactly per the published guidelines so
// deployments are attributed to the publisher. The resource must be
// unconditional — Partner Center's validator scans the static template and
// rejects gated/conditional pid resources. The deployment is a no-op; it
// creates no Azure resources and incurs no cost.
//
// Docs: https://aka.ms/aboutinfluencedrevenuetracking
resource pidTracker 'Microsoft.Resources/deployments@2025-04-01' = {
name: 'pid-9c5e2451-3441-4d93-bf65-d72155854568-partnercenter'
properties: {
mode: 'Incremental'
template: {
'$schema': 'https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#'
contentVersion: '1.0.0.0'
resources: []
}
}
}
// ── Modules ───────────────────────────────────────────────────────────────────
module network 'modules/network.bicep' = {
name: 'network-${env}'
params: {
env: env
location: location
enableNatGateway: enableNatGateway
}
}
module keyvault 'modules/keyvault.bicep' = {
name: 'keyvault-${env}'
params: {
env: env
location: location
deployerPrincipalId: effectiveDeployerPrincipalId
// Token defaulting happens via newGuid() in main.bicep's parameter default;
// here we just forward the value.
bootstrapToken: bootstrapToken
postgrestJwtSecret: postgrestJwtSecret
authProvider: authProvider
tenantOnboardingMode: tenantOnboardingMode
initialAdminObjectId: initialAdminObjectId
pgrstAuthenticatorPassword: pgrstAuthenticatorPassword
// authStateSecret default in the module mints a fresh GUID. We don't
// pass it through from main.bicep — there's no need to plumb a
// sensitive value across modules, and it doesn't get used outside
// the KV anyway.
}
}
// ── ACR (issue 002) ──────────────────────────────────────────────────────────
module acr 'modules/acr.bicep' = if (createAcr) {
name: 'acr-${env}'
params: {
env: env
location: location
acrSku: acrSku
}
}
// ── Postgres (issue 003) ─────────────────────────────────────────────────────
var deployPostgres = !empty(pgAdminPassword)
module postgres 'modules/postgres.bicep' = if (deployPostgres) {
name: 'postgres-${env}'
params: {
env: env
location: location
peSubnetId: network.outputs.peSubnetId
pgDnsZoneId: network.outputs.pgDnsZoneId
pgSku: pgSku
pgStorageGb: pgStorageGb
pgBackupRetentionDays: pgBackupRetentionDays
enableHa: enableHa
adminUser: pgAdminUser
adminPassword: pgAdminPassword
}
}
// PostgREST connection URI — written to KV as `pgrst-db-uri`. The PostgREST
// container app references this secret to populate PGRST_DB_URI. Constructed
// here (not inside keyvault.bicep) so the KV module stays decoupled from
// Postgres specifics, and so the FQDN comes straight from the postgres module
// output, giving Bicep a clean dependency edge. Issue 015 will retire this
// password-based connection once PostgREST also moves to MI auth.
module pgrstDbUriSecret 'modules/keyvault-secret.bicep' = if (deployPostgres) {
name: 'pgrst-db-uri-secret-${env}'
params: {
keyVaultName: keyvault.outputs.keyVaultName
secretName: 'pgrst-db-uri'
secretValue: 'postgres://${pgAdminUser}:${pgAdminPassword}@${postgres!.outputs.pgFqdn}:5432/postgres'
}
}
// ── Storage (issue 005) ──────────────────────────────────────────────────────
module storage 'modules/storage.bicep' = {
name: 'storage-${env}'
params: {
env: env
location: location
peSubnetId: network.outputs.peSubnetId
blobDnsZoneId: network.outputs.blobDnsZoneId
containerName: storageContainerName
// backendPrincipalId omitted — module defaults to '' and the role
// assignment is gated on (!empty). Passing a literal '' here would
// make TTK's "Parameter Types Should Be Consistent" rule throw
// "Cannot bind argument to parameter 'Match' because it is an empty
// string" while extracting the parameter name from the wiring.
}
}
// ── Managed Identity (issue 006) ─────────────────────────────────────────────
// Created before Container Apps so AcrPull + KV Secrets User roles are already
// assigned when the apps start — avoids the system-assigned MI timing race.
var deployApps = !empty(backendImage)
var anonymousPull = imagePullAuth == 'anonymous'
// In anonymous-pull mode the customer never has a private ACR, so we don't
// resolve a resource ID for one — and the MI module skips AcrPull anyway.
var acrResourceId = anonymousPull
? ''
: (createAcr ? acr!.outputs.acrId : resourceId('Microsoft.ContainerRegistry/registries', 'acrmike${env}'))
module mi 'modules/managed-identity.bicep' = if (deployApps) {
name: 'mi-${env}'
params: {
env: env
location: location
acrId: acrResourceId
keyVaultId: keyvault.outputs.keyVaultId
storageAccountId: storage.outputs.storageAccountId
grantAcrPull: !anonymousPull
}
}
// ── Container Apps Environment (issue 006) ────────────────────────────────────
module cae 'modules/containerapps-env.bicep' = if (deployApps) {
name: 'cae-${env}'
params: {
env: env
location: location
caeSubnetId: network.outputs.caeSubnetId
}
}
module postgrest 'modules/containerapp-postgrest.bicep' = if (deployApps) {
name: 'postgrest-${env}'
params: {
caeId: cae!.outputs.caeId
location: location
postgrestImage: postgrestImage
keyVaultBase: keyvault.outputs.keyVaultUri
miId: mi!.outputs.miId
authProvider: authProvider
imagePullAuth: imagePullAuth
}
// pgrst-db-uri KV secret must exist before the revision tries to resolve it.
// Bicep does not infer this through keyVaultBase (just a string), so make the
// edge explicit. When the secret module's own `if (deployPostgres)` gate is
// false the module is not deployed and Bicep drops this edge automatically.
//
// The Container App's PGRST_DB_URI secretRef resolves to KV's
// `pgrst-db-uri`. In entra mode that secret is seeded by
// pgrstDbUriSeed (after keyvault + postgres outputs are available);
// without this dependsOn, postgrest could attempt to provision
// before the KV secret exists, leaving the revision failed. In
// non-entra modes pgrstDbUriSeed isn't deployed, so no dependency.
// See gap #24.
dependsOn: (deployPostgres && authProvider == 'entra')
? [pgrstDbUriSecret, pgrstDbUriSeed]
: [pgrstDbUriSecret]
}
module backendApp 'modules/containerapp-backend.bicep' = if (deployApps) {
name: 'backend-${env}'
params: {
caeId: cae!.outputs.caeId
location: location
backendImage: backendImage
keyVaultBase: keyvault.outputs.keyVaultUri
keyVaultName: keyvault.outputs.keyVaultName
storageAccountName: storage.outputs.storageAccountName
postgrestInternalUrl: 'http://postgrest'
// Derive from Container Apps default domain when the param is empty
// (the standard marketplace path). The backend Container App's FQDN
// is `backend.<caeDefaultDomain>` — same hostname the operator will
// land on in the browser. Custom-domain wiring happens post-install
// through /install, not via this param. See 040 Entry 9.
//
// Module param is named frontendOrigin (not frontendUrl) so ARM-TTK's
// "URIs Should Be Properly Constructed" rule — which fires on any
// *Url-named property emitting outer format()/concat() — doesn't
// match. The value is semantically a CORS allow-origin anyway.
frontendOrigin: empty(frontendUrl) ? 'https://backend.${cae!.outputs.caeDefaultDomain}' : frontendUrl
authProvider: authProvider
miId: mi!.outputs.miId
miClientId: mi!.outputs.miClientId
imagePullAuth: imagePullAuth
}
}
// Write backend-public-url to KV at deploy time so the install configurator's
// Core setup row is pre-green and the operator never sees the in-app form for
// a value Bicep already knows. The FQDN is the backend Container App's
// auto-generated public hostname; identical to what the operator's browser is
// pointing at when they reach /install. Closes 040 Entry 1.
module backendPublicUrlSecret 'modules/keyvault-secret.bicep' = if (deployApps) {
name: 'backend-public-url-${env}'
params: {
keyVaultName: keyvault.outputs.keyVaultName
secretName: 'backend-public-url'
secretValue: 'https://${backendApp!.outputs.backendFqdn}'
}
}
module migrateJob 'modules/containerapp-job-migrate.bicep' = if (deployApps) {
name: 'db-migrate-${env}'
params: {
caeId: cae!.outputs.caeId
location: location
backendImage: backendImage
keyVaultBase: keyvault.outputs.keyVaultUri
miId: mi!.outputs.miId
pgHost: deployPostgres ? postgres!.outputs.pgFqdn : ''
pgDatabase: 'postgres'
pgMiUsername: migrationPgMiUsername
imagePullAuth: imagePullAuth
}
// The job's `database-url` secret resolves via keyVaultRef to the
// db-admin-uri secret seeded by dbAdminUriSeed (below). Container
// Apps validates secretRefs at provision time and the job creation
// FAILS if the target KV secret doesn't exist yet — observed on
// rg-mike-mtest1 2026-05-20 (InvalidParameterValueInContainerTemplate
// 'Unable to get value using Managed identity ... for secret
// database-url'). Bicep doesn't infer this dep automatically because
// we don't reference dbAdminUriSeed.outputs from this module call.
dependsOn: deployPostgres ? [dbAdminUriSeed] : []
}
// Seed pgrst-db-uri with PostgREST authenticator credentials, AFTER
// both keyvault and postgres modules have run (pgFqdn comes from
// postgres outputs). The migration job's ensureAuthenticatorRole()
// creates the role with the matching password.
//
// Gated to entra mode only: in supabase/local mode the migration job
// reads pgrst-db-uri as DATABASE_URL to run DDL, which requires admin
// privileges that the authenticator role doesn't have. Those modes
// continue to use whatever pgrst-db-uri was seeded by the marketplace
// pipeline (typically mikeadmin credentials). See gap #24.
module pgrstDbUriSeed 'modules/pgrst-db-uri.bicep' = if (deployApps && deployPostgres && authProvider == 'entra') {
name: 'pgrst-db-uri-${env}'
params: {
keyVaultName: keyvault.outputs.keyVaultName
pgFqdn: postgres!.outputs.pgFqdn
pgrstAuthenticatorPassword: pgrstAuthenticatorPassword
}
}
// Seed db-admin-uri with the Postgres mikeadmin connection string. The
// migrate job uses THIS (not pgrst-db-uri) for its DATABASE_URL because
// node-pg-migrate needs CREATE TABLE / CREATE ROLE privileges that the
// authenticator role doesn't have. Without this, every fresh marketplace
// install bricks at the post-deploy-migrate step — surfaced on
// rg-mike-test4 2026-05-19, fixed in 1.0.5.
module dbAdminUriSeed 'modules/db-admin-uri.bicep' = if (deployApps && deployPostgres) {
name: 'db-admin-uri-${env}'
params: {
keyVaultName: keyvault.outputs.keyVaultName
pgFqdn: postgres!.outputs.pgFqdn
pgAdminUser: pgAdminUser
pgAdminPassword: pgAdminPassword
}
}
// Auto-trigger the migrate job once it exists. Without this, a fresh
// greenfield install would land with no schema in Postgres — the
// operator's first authenticated request fails on missing tables /
// roles. See docs/issues/azure-migration/036-marketplace-install-gaps.md
// gap #20.
//
// dependsOn now includes dbAdminUriSeed unconditionally (deployPostgres
// is the only gate) because the migrate job's DATABASE_URL secret
// resolves to db-admin-uri — that secret must exist before the
// deploymentScript triggers the job. pgrstDbUriSeed dependency kept
// for entra-mode installs so PostgREST has its own connection string
// when its revision lands.
module postDeployMigrate 'modules/post-deploy-migrate.bicep' = if (deployApps) {
name: 'post-deploy-migrate-${env}'
params: {
location: location
env: env
miId: mi!.outputs.miId
miPrincipalId: mi!.outputs.miPrincipalId
}
dependsOn: (deployPostgres && authProvider == 'entra')
? [migrateJob, dbAdminUriSeed, pgrstDbUriSeed]
: (deployPostgres ? [migrateJob, dbAdminUriSeed] : [migrateJob])
}
// ── Outputs ───────────────────────────────────────────────────────────────────
output resourceGroup string = resourceGroup().name
output vnetId string = network.outputs.vnetId
output caeSubnetId string = network.outputs.caeSubnetId
output peSubnetId string = network.outputs.peSubnetId
output keyVaultName string = keyvault.outputs.keyVaultName
// keyVaultUri is intentionally not exposed as a top-level output. ARM-TTK's
// "URIs Should Be Properly Constructed" rule fires on any URI-named output
// whose value contains a format() expression — which Bicep emits whenever
// the value flows through a cross-module reference() chain. No external
// consumer needs it (viewDefinition.json reads only keyVaultName), so the
// simplest fix is to omit it. Operators can construct the URI from
// keyVaultName: `https://{keyVaultName}.vault.azure.net/`.
// In anonymous-pull mode there is no customer-side ACR — surface the public
// publisher registry that images are actually being pulled from instead.
output acrLoginServer string = anonymousPull ? containerRegistry : (createAcr ? acr!.outputs.acrLoginServer : containerRegistry)
output pgFqdn string = deployPostgres ? postgres!.outputs.pgFqdn : ''
output storageAccountName string = storage.outputs.storageAccountName
output backendFqdn string = deployApps ? backendApp!.outputs.backendFqdn : ''
output miPrincipalId string = deployApps ? mi!.outputs.miPrincipalId : ''
// Install configurator URL — surfaced to the deployer post-deploy so they can
// open /install. The bootstrap token deliberately is NOT exposed as a
// deployment output (TTK rule "Outputs Must Not Contain Secrets" rejects
// secure outputs in marketplace packages); read it directly from KV with:
// az keyvault secret show --vault-name <kv> --name install-bootstrap-token
//
// uri() wrapping satisfies TTK's "URIs Should Be Properly Constructed" rule —
// the outer uri(...) appears before any inner format()/concat() in the
// emitted ARM expression, which is what the rule looks for.
output installUrl string = deployApps ? uri('https://${backendApp!.outputs.backendFqdn}', '/install') : ''
|