A production-readiness backlog from a 2026-05-05 Bicep + workflow audit — explicitly not dev-environment bugs, but gating items before any real-customer or marketplace deployment, organized Critical/High/Medium/Low. Critical items include Postgres geo-redundant backups Disabled, public network access on Key Vault and ACR, a missing NSG on the externally-exposed subnet-cae, verifying the migration job actually uses the MI/AAD path not the DATABASE_URL fallback, and the PostgREST schema-cache reload gap after migrations (preferred fix: NOTIFY pgrst, 'reload schema' from the migrate job). It also documents the shape issue 014 actually shipped (backend→ PostgREST is unauthenticated in entra mode, trust via network isolation, shared HMAC secret gone) and the still-open Entra group→Postgres role/RLS mapping, plus the deliberate no-self-service-account-closure decision on Entra and the tenant-admin console/audit/export gaps. Use as the checklist to harden a deployment for real customer data or before a marketplace ship, and to understand the entra-mode DB trust model.
Azure production-hardening backlog
Findings from a Bicep + workflow audit on 2026-05-05. None of these are bugs in the dev environment (rg-mike-dev is provisioned and working) — they are gating items for any environment that holds real customer data, and prerequisites for the Marketplace listing in issue 016.
Treat this file as the production-readiness checklist. Tick items off as they land.
Critical — must be resolved before prod or marketplace ship
- [ ] Postgres geo-redundant backups —
geoRedundantBackup: 'Disabled'in infra/main.parameters.prod.json (also defaulted Disabled in infra/modules/postgres.bicep). A regional outage today means data loss with no RPO. Production must setgeoRedundantBackup: 'Enabled'and document the recovery RTO/RPO in a runbook. - [ ] Key Vault public network access —
publicNetworkAccess: 'Enabled'in infra/modules/keyvault.bicep. The comment claims this is so the deploy pipeline can populate secrets, but no firewall rule constrains who on the public internet may attempt access (Entra AuthN alone is not sufficient as a defence-in-depth control). Switch to private endpoint + a tight IP allowlist for the GitHub Actions runner egress range. - [ ] ACR public network access —
publicNetworkAccess: 'Enabled'in infra/modules/acr.bicep. Same shape as KV. Restrict to private endpoint or, for marketplace, accept public reads only against a separate publisher ACR (see issue 016). - [ ] NSG missing on Container Apps subnet —
subnet-caein infra/modules/network.bicep has no NSG attached. Backend ingress isexternal: trueso the public internet can reach it directly. Add an NSG limiting inbound to TCP/443 and explicit deny on everything else. - [ ] Migration job MI auth uses fallback path in prod — issue 015 was committed as "graceful fallback" not "switch". Verify
runMigrations.tsactually takes the AAD path (not theDATABASE_URLfallback) when deployed, by removingDATABASE_URLfrom the migration job's env. Otherwise the password-based path remains live and a leaked password is still a credential. - [ ] PostgREST schema-cache reload after migrations. PostgREST builds its REST model from
pg_catalogat startup and does not see schema changes (new tables, new columns) until told to refresh. The CI deploy pipeline runsdb-migrateand then promotes the backend, but does not currently reload PostgREST's schema cache — the first request that touches a freshly-added column will 500 with"Could not find the 'X' column of 'Y' in the schema cache". Two ways to fix:- After the migrate job succeeds and before the backend update, run
az containerapp revision restartagainst thepostgrestContainer App. Heavyweight (drops all in-flight requests). - Run
psql … -c "NOTIFY pgrst, 'reload schema'"against the Postgres server from the migration job container. Lightweight, no restart needed. Requires the migration job to know the Postgres host + a way to authenticate (it already does — issue 015 gave it MI access). Option 2 is preferable. Wire it intorunMigrations.tsafter thenode-pg-migrateinvocation succeeds.
- After the migrate job succeeds and before the backend update, run
High — schedule before any customer-facing deploy
- [ ] Postgres tier + HA —
Standard_B2s(Burstable) withenableHa: falsein prod params. Burstable is unsupported for production workloads by Microsoft's own guidance and there is no automatic failover. Move to General PurposeStandard_D2s_v3minimum and enable zone-redundant HA. - [ ] Postgres storage auto-grow —
autoGrow: 'Disabled'with 32 GB hardcoded. Database goes read-only when the disk fills. Enable auto-grow OR provision an alert at 80% disk usage. - [ ] Managed Identity role scope — infra/modules/managed-identity.bicep assigns AcrPull, KV Secrets User, and Storage Blob Contributor at resource group scope. Reduce to the specific resource (ACR, KV, Storage Account) so future apps deployed into the same RG don't inherit access.
- [ ] GitHub Actions SP role —
setup-github-oidc.shgrantsContributoron the resource group with a "tighten later" comment. Replace with a custom role limited to: AcrPush, AcrPull, Container App update, Container App job start. No KV write permission needed once secrets are seeded. - [ ] Storage account redundancy —
Standard_LRSin infra/modules/storage.bicep is single-DC. Documents are user data; production should be GZRS or at minimum GRS. - [ ] PostgREST connection string contains admin password — infra/modules/containerapp-postgrest.bicep references the
postgres-admin-passwordsecret. Once issue 014 lands, switch PostgREST to its own role authenticated via Entra (or rotate to a non-admin role with onlyusageonpublic).
Medium — ops hygiene
- [ ] No diagnostic settings on Postgres / KV / ACR / Storage. Without these, security incidents and connectivity issues cannot be investigated post-hoc. Provision a Log Analytics workspace (removed from the base template) and wire each resource's
Microsoft.Insights/diagnosticSettingsto it. Set retention to ≥90 days for compliance / forensic timelines. - [ ] NAT Gateway disabled in dev but private endpoints are provisioned. Backend Container App does not have a stable outbound IP; certain private-endpoint flows depend on this. Either enable NAT or remove the private endpoints in dev to keep the dev model coherent.
- [ ] Storage account soft delete 7 days. Bump to 30 days for accidental-delete recovery.
- [ ] No NSG on private endpoint subnet (
subnet-pe). Defense in depth — add an NSG that allows onlysubnet-cae→ 5432 (Postgres) and 443 (Blob). - [ ] No private endpoint for ACR. Backend pulls via MI which is fine, but a private endpoint removes the public surface entirely.
Low — clarity / portability
- [ ] Bicep defaults vs prod params —
pgSkudefault inmain.bicepisB1msbut prod params override toB2s. Either drop the default or make it non-misleading. - [ ] Hardcoded VNet CIDR (
10.0.0.0/16) — assumes no peering or on-premises network overlaps. Either parameterize or document the assumption. - [ ] ACR name (
acrmike${env}) is globally unique; collision risk is low but possible. For marketplace, parameterize.
Architecture — DB-layer authorization
Issue 014 landed, but with a different shape than the original spec. After review, switching the backend → PostgREST path from HMAC to JWKS would have replaced one form of vestigial validation with another — neither was actually doing useful access-control work given the network model. What ships instead:
- Backend → PostgREST is unauthenticated in entra mode. PostgREST has
external: falseingress (Container Apps platform-enforced); only the backend can reach it. The backend stripsAuthorizationandapikeyheaders before calling PostgREST. PostgREST has noPGRST_JWT_*configuration in entra mode and runs every request asservice_role(PGRST_DB_ANON_ROLE). Trust comes from network isolation, not from JWT validation. - The shared HMAC secret is gone in entra mode. No
pgrst-jwt-secretKey Vault entry, noSUPABASE_SECRET_KEYenv var on the backend. - Supabase / local mode is unchanged — HMAC validation + role-claim is preserved for the OSS / single-tenant / local Docker paths.
What is not done — and is the next ticket on top of 014:
- [ ] Entra group → Postgres role mapping. Today every backend query in entra mode runs as
service_roleand DB-level RLS is not enforcing per-user policy. The future system would re-introduce JWT validation, but doing real work this time:- Forward the user's Entra token to PostgREST (not the backend's MI token), so the DB layer sees who the actual caller is.
- PostgREST validates the user's token via JWKS against the customer's tenant — same trust source the backend already uses for issue-009 validation.
- Map Entra
groupsclaim values to Postgres roles, either viaPGRST_JWT_ROLE_CLAIM_KEYpointing at a tenant-config-driven custom claim, or via aSET ROLESECURITY DEFINER function fed from the JWT. - Define Postgres roles (TenantAdmin, Member, etc.) and grant per-row RLS policies that key off
auth.jwt() ->> 'oid'-style predicates, so RLS becomes the second line of defence behind the backend'scheckProjectAccesshelpers. - The backend keeps its MI token only for system-internal operations (cron jobs, migrations, admin tasks).
Until that system lands, do not assume a leak in the backend's authorization checks would be caught at the database layer in entra mode. The backend is the access-control authority; PostgREST is a transport.
Tenant-admin self-service tooling (entra mode only)
The user-facing app deliberately does not offer self-service account closure on Entra tenants — the identity is owned by the customer's directory and any whitelisted group member can sign back in immediately, so a "Delete Account" button is misleading. The current behaviour: the button is hidden in entra mode and the backend rejects DELETE /user/account with a 403 directing the user to contact their tenant admin.
This leaves a real gap to close before the marketplace listing:
- [ ] Tenant-admin console for account closure / data erasure on a target user. Needs a TenantAdmin-only API that runs the same cascade currently in
DELETE /user/accountfor an arbitraryuser_id. Optionally: also queues a Graph API call to remove the user from the access group, given the right delegated permissions. - [ ] Audit trail for admin-triggered erasures — who erased whom, when, and at which IdP user_id. GDPR records-of-processing requirement.
- [ ] Self-service "Export my data" as the GDPR Art. 15 counterpart. Lower priority but expected by enterprise customers.
What this file does NOT cover
- Bugs in the running code — those go straight to fix commits.
- Performance tuning (PG indexes, Container Apps replica counts, etc.).
- Marketplace packaging — see issue 016.