# Azure AI Landing Zone

## Overview

The Azure AI Landing Zone is an enterprise-scale, production-ready reference architecture designed to deploy secure and resilient AI applications and agents on Azure. This repository contains the Bicep implementation, the Terraform implementations are available in separate repositories.   

![Architecture Diagram](media/Architecture%20Diagram.png)

## What's new in v2

The v2 line adds two things that matter most for everyday use:

1. **A topology switch** — set `deploymentMode` to one of:
    - **`standalone`** — the AI Landing Zone provisions everything it needs (VNet, private endpoints, Bastion, jumpbox, NAT Gateway, observability). Best for sandboxes, evaluations, and teams without a corporate hub.
    - **`ailz-integrated`** — the AI Landing Zone deploys only the **spoke** (VNet + private endpoints + AI services) and peers into a hub VNet you already operate, **reusing** the hub's Firewall, Bastion, Private DNS zones, and Log Analytics workspace. Best for production inside an existing Azure Landing Zone.
2. **Granular reuse of existing resources** — every platform service can be brought from the outside via an `existing*ResourceId` parameter (cross-subscription IDs are accepted): Log Analytics, Application Insights, Private DNS zones (per zone, 15 available), hub VNet, jumpbox, Bastion, NAT Gateway, route table.

A handful of other quality-of-life additions:

- **`allowedIpRanges`** — let named CIDRs reach the data plane of Storage, Key Vault, Cosmos DB, AI Search, ACR, AI Foundry, and App Configuration without disabling private endpoints. Use this when developers need to query the workload from their laptops without routing through Bastion.
- **Decoupled hub components** — `deployJumpbox`, `deployBastion`, and `deployNatGateway` are now independent flags. No more all-or-nothing `deployVM`.
- **Hub integration helpers** — `hubIntegration.hubVnetResourceId` creates the spoke→hub peering for you; `hubIntegration.egressNextHopIp` routes spoke egress through your hub firewall / NVA.
- **Pre-flight validation** — `scripts/Invoke-PreflightChecks.ps1` runs automatically as an `azd preprovision` hook and catches the usual mistakes (CIDR overlap, undersized subnets, missing BYO resource IDs, conflicting flags) before they reach ARM. Bypass with `PREFLIGHT_SKIP=true`.

**Pick a runbook to deploy:**

- **[Standalone deployment](docs/runbook-standalone.md)** — single subscription, AI LZ owns all networking and platform resources.
- **[Hub-and-spoke deployment](docs/runbook-hub-spoke.md)** — spoke inside an existing Landing Zone, hub provides the platform.

If you're upgrading from v1.x, see the **[migration guide](docs/v2-migration.md)** — it shows what changed in v2 and the parameters you may need to update.

## How to Deploy

Choose your preferred deployment method based on project requirements and environment constraints.

### Prerequisites

**Required Permissions:**

- Azure subscription with **Contributor** and **User Access Admin** roles
- Agreement to Responsible AI terms for Azure AI Services

**Required Tools:**

- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)
- [Azure Developer CLI](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd)
- [Git](https://git-scm.com/downloads)

> Azure CLI is included as a prerequisite for future pre/post provisioning hooks that may depend on it.

### Basic Deployment

Quick setup for demos without network isolation.

**Initialize the project**

```
azd init -t azure/bicep-ptn-aiml-landing-zone
```

**Sign in to Azure**

```
az login
azd auth login
```

> Add `--tenant` for `az` or `--tenant-id` for `azd` if you want a specific tenant.

**Provision Infrastructure**

```
azd provision
```
> **Optional:** You can change parameter values in `main.parameters.json` or set them using `azd env set` before running `azd provision`. The latter applies only to parameters that support environment variable substitution.

### Zero Trust Deployment

For deployments that **require network isolation**.

**Before Provisioning**

Enable network isolation in your environment:

```
azd env set NETWORK_ISOLATION true
```

> **Optional:** Update other parameters in `main.parameters.json` or via `azd env set` before provisioning.

Make sure you're signed in with your Azure user account:

```
az login
azd auth login
```

> Add `--tenant` for `az` or `--tenant-id` for `azd` if you want a specific tenant.

**Provision Infrastructure**

```
azd provision
```

**Using the Jumpbox VM**

1. **Reset the VM password** in the Azure Portal (required on first access if not set in deployment parameters):

   - Go to your VM resource → **Support + troubleshooting** → **Reset password** → Set new credentials
   - Default username is `testvmuser`

2. **Connect via Azure Bastion**

#### Cloning extra repositories onto the jumpbox

The default `install.ps1` bootstrap clones this repository to `C:\github\ai-lz` and walks `manifest.json#components` for additional repos. Downstream solution accelerators that consume this landing zone as a Bicep module / git submodule and need their own application repository present on the jumpbox (for private-network data-plane post-provisioning — Cosmos seeding, AI Search index creation, sample data loading, etc.) declare those repos in their **overlay** `manifest.json`:

```json
{
  "tag": "v1.0.0",
  "ailz_tag": "v1.1.1",
  "components": [
    {
      "name": "voice-app",
      "repo": "https://github.com/Contoso/voice-app.git",
      "tag": "v0.3.0"
    }
  ]
}
```

`main.bicep` derives the URLs/tags/names from `_manifest.components` at compile time and forwards them to `install.ps1` over the CSE `commandToExecute`. Each entry is cloned into `C:\github\<name>` on the jumpbox. `tag` defaults to `main`; `name` defaults to the repo URL basename without `.git`. There are no per-deployment Bicep parameters to wire — `manifest.json` is the single source of truth, the same one consumers already use to pin their `ailz_tag` release.

#### Building and pushing images with network isolation

When `networkIsolation=true`, the Container Registry is deployed as **Premium** with `publicNetworkAccess=Disabled` and is only reachable via its private endpoint. `az acr build` against the shared Microsoft-managed builder will fail. This landing zone therefore provisions an **ACR Tasks agent pool** attached to the `devops-build-agents-subnet` so image builds run inside the VNet and push to the registry over its private endpoint. No Docker client is required (and the jumpbox has no Docker installed by design — see issue #14).

Build and push from the jumpbox (or any client that can reach ARM):

```powershell
$acr  = (azd env get-values | Select-String '^AZURE_CONTAINER_REGISTRY_ENDPOINT').Line.Split('=')[1].Trim('"').Split('.')[0]
$pool = (azd env get-values | Select-String '^ACR_TASK_AGENT_POOL').Line.Split('=')[1].Trim('"')

az acr build `
  -r $acr `
  --agent-pool $pool `
  -t myapp:latest `
  -f Dockerfile `
  .
```

Pause billing between builds (default tier `S1` is billed per hour whether idle or not):

```powershell
az acr agentpool update -r <acr> -n <pool> --count 0
```

Resume before the next build:

```powershell
az acr agentpool update -r <acr> -n <pool> --count 1
```

The agent pool can be disabled entirely with `deployAcrTaskAgentPool=false` if builds are handled by a central CI/CD runner that already reaches the registry's private endpoint.

#### Firewall egress allow-list (network isolation)

When `networkIsolation=true`, egress from the jumpbox and workload subnets is forced through the default Azure Firewall. The landing zone codifies the FQDNs required by the default `install.ps1` bootstrap and by the ACR Tasks agent pool. The set is split by purpose so you can audit or trim it:

- ACR Tasks control plane and registry: `*.azurecr.io`, `*.data.azurecr.io`, and Azure Storage queue/blob/table FQDNs.
- Language/runtime feeds: Python.org, PyPI, npm.
- OS package feeds: Debian, Ubuntu, Yarn, and `packages.microsoft.com` for Microsoft-supported Linux packages such as `msodbcsql18`.

If your application build needs additional HTTPS endpoints, add them to the `additionalAcrTaskBuildFqdns` array parameter. The values are appended to the ACR Tasks HTTPS runtime rule only when `networkIsolation`, `deployAzureFirewall`, `deployAcrTaskAgentPool`, and `extendFirewallForAcrTaskBuilds` are all enabled, and are scoped to the `devops-build-agents-subnet`.

| Rule | Source subnet | FQDN group | Used by |
| --- | --- | --- | --- |
| `AllowMicrosoftContainerRegistry` | `*` | `mcr.microsoft.com`, `*.data.mcr.microsoft.com` | ACA/agents/ACR Tasks pulling Microsoft base images |
| `AllowEntraIdAuth` | `*` | `login.microsoftonline.com`, `login.windows.net`, `management.azure.com`, `graph.microsoft.com`, `*.applicationinsights.azure.com` | Entra ID auth, ARM control plane, App Insights telemetry |
| `AllowGitHub` | `*` | `github.com`, `*.github.com`, `raw.githubusercontent.com`, `codeload.github.com`, `objects.githubusercontent.com`, `*.githubusercontent.com` | Repo clones, release downloads |
| `AllowJumpboxBootstrap` | `jumpboxSubnetPrefix` | Chocolatey, NuGet, VS Installer, `download.microsoft.com`, `aka.ms`, `go.microsoft.com`, `*.core.windows.net`, `*.azureedge.net` | `choco install`, VS Code/PowerShell Core/Azure CLI/AZD MSIs (Python is installed from python.org embeddable zip — see `AllowJumpboxDevRuntimes`) |
| `AllowJumpboxDevRuntimes` | `jumpboxSubnetPrefix` | `*.python.org`, `*.pypi.org`, `*.pythonhosted.org`, `*.pypa.io`, `*.npmjs.org` | `pip install`, `npm install`, jumpbox Python embeddable-zip install + `get-pip.py` bootstrap |
| `AllowJumpboxEditors` | `jumpboxSubnetPrefix` | `update.code.visualstudio.com`, `*.vo.msecnd.net`, `*.vscode-cdn.net` | VS Code updates |
| `AllowJumpboxAcme` | `jumpboxSubnetPrefix` | `api.github.com`, `acme-v02.api.letsencrypt.org` | win-acme release discovery + ACME v2 issuance/renewal from jumpbox |
| `AllowAcrTasks` | `devopsBuildAgentsSubnetPrefix` | `*.azurecr.io`, `*.data.azurecr.io` | ACR Tasks agent pool talking to its registry |

Set `extendFirewallForJumpboxBootstrap=false` to skip the jumpbox-scoped rules when egress is managed centrally by another policy.

### Permissions

The following role assignments are provisioned by the template based on the **default configuration** in `main.parameters.json`. This includes the default set of container apps, their associated roles, and the services they interact with. If you customize the parameters before provisioning — such as adding or removing container apps or changing role mappings — the actual assignments will vary accordingly.

#### Microsoft Foundry and AI Search Assignments

| Resource | Role | Assignee | Description |
| --- | --- | --- | --- |
| Microsoft Foundry Account | Cognitive Services User | Search Service | Allow Search Service to access vectorizers |
| GenAI App Search Service | Search Index Data Reader | Microsoft Foundry Project | Read index data |
| GenAI App Search Service | Search Service Contributor | Microsoft Foundry Project | Create AI Search connection |
| GenAI App Storage Account | Storage Blob Data Reader | Microsoft Foundry Project | Read blob data |
| GenAI App Storage Account | Storage Blob Data Reader | Search Service | Read blob data for indexing |

#### Container App Role Assignments

Current default configuration provisions a single Hello World container app (`orchestrator`), so only the assignments below are expected by default.

| Resource | Role | Assignee | Description |
| --- | --- | --- | --- |
| GenAI App Configuration Store | App Configuration Data Reader | ContainerApp: orchestrator | Read configuration data |
| GenAI App Container Registry | AcrPull | ContainerApp: orchestrator | Pull container images |
| GenAI App Key Vault | Key Vault Secrets User | ContainerApp: orchestrator | Read secrets |
| GenAI App Search Service | Search Index Data Reader | ContainerApp: orchestrator | Read index data |
| GenAI App Storage Account | Storage Blob Data Reader | ContainerApp: orchestrator | Read blob data |
| GenAI App Cosmos DB | Cosmos DB Built-in Data Contributor | ContainerApp: orchestrator | Read/write Cosmos DB data |
| Microsoft Foundry Account | Cognitive Services User | ContainerApp: orchestrator | Access Cognitive Services |
| Microsoft Foundry Account | Cognitive Services OpenAI User | ContainerApp: orchestrator | Use OpenAI APIs |

#### Executor Role Assignments

| Resource | Role | Assignee | Description |
| --- | --- | --- | --- |
| GenAI App Configuration Store | App Configuration Data Owner | Executor | Full control over configuration settings |
| GenAI App Container Registry | AcrPush | Executor | Push container images |
| GenAI App Container Registry | AcrPull | Executor | Pull container images |
| GenAI App Key Vault | Key Vault Contributor | Executor | Manage Key Vault settings |
| GenAI App Key Vault | Key Vault Secrets Officer | Executor | Create Key Vault secrets |
| GenAI App Search Service | Search Service Contributor | Executor | Create/update search service elements |
| GenAI App Search Service | Search Index Data Contributor | Executor | Read/write search index data |
| GenAI App Search Service | Search Index Data Reader | Executor | Read index data |
| GenAI App Storage Account | Storage Blob Data Contributor | Executor | Read/write blob data |
| GenAI App Cosmos DB | Cosmos DB Built-in Data Contributor | Executor | Read/write Cosmos DB data |
| Microsoft Foundry Account | Cognitive Services OpenAI User | Executor | Use OpenAI APIs |
| Microsoft Foundry Account | Cognitive Services User | Executor | Access Cognitive Services |

#### Jumpbox VM Role Assignments

| Resource | Role | Assignee | Description |
| --- | --- | --- | --- |
| Resource Group | Reader | Jumpbox VM | Enumerate ARM resources from inside the VNet (`az resource list`, `az cosmosdb list`, `az containerapp list`, …) for postProvision / data-seed scripts |
| GenAI App Container Apps | Container Apps Contributor | Jumpbox VM | Full control over Container Apps |
| Azure Managed Identity | Managed Identity Operator | Jumpbox VM | Assign and manage user-assigned identities |
| GenAI App Container Registry | Container Registry Repository Writer | Jumpbox VM | Write to ACR repositories |
| GenAI App Container Registry | Container Registry Tasks Contributor | Jumpbox VM | Manage ACR tasks |
| GenAI App Container Registry | Container Registry Data Access Configuration Administrator | Jumpbox VM | Manage ACR data access configuration |
| GenAI App Container Registry | AcrPush | Jumpbox VM | Push container images |
| GenAI App Configuration Store | App Configuration Data Owner | Jumpbox VM | Full control over configuration settings |
| GenAI App Key Vault | Key Vault Contributor | Jumpbox VM | Manage Key Vault settings |
| GenAI App Key Vault | Key Vault Secrets Officer | Jumpbox VM | Create Key Vault secrets |
| GenAI App Key Vault | Key Vault Certificates Officer | Jumpbox VM | Import/manage Key Vault certificates for public ingress TLS |
| GenAI App Search Service | Search Service Contributor | Jumpbox VM | Create/update search service elements |
| GenAI App Search Service | Search Index Data Contributor | Jumpbox VM | Read/write search index data |
| GenAI App Storage Account | Storage Blob Data Contributor | Jumpbox VM | Read/write blob data |
| GenAI App Cosmos DB | Cosmos DB Built-in Data Contributor | Jumpbox VM | Read/write Cosmos DB data |
| Microsoft Foundry Account | Cognitive Services Contributor | Jumpbox VM | Manage Cognitive Services resources |
| Microsoft Foundry Account | Cognitive Services OpenAI User | Jumpbox VM | Use OpenAI APIs |

### Optional Public Ingress (Application Gateway WAF v2)

**Issue #49.** The landing zone provisions the Container Apps environment in **internal** mode under network isolation, so its apps are unreachable from the public Internet by default. Some workloads need a controlled, audited public entry point (a tester, a partner integration, a public demo). The optional `publicIngress` feature deploys an **Application Gateway WAF v2** in front of the internal ACA environment without changing any of the existing internal topology.

> ⚠️ **Cost warning.** Enabling this feature deploys WAF_v2 + a Standard Public IP, which incur **hourly charges even when idle** (~USD 240/month for the gateway alone, region-dependent). Keep `publicIngress.enabled = false` unless actively needed and tear the stack down with `azd down` (or delete the resources manually) when the access window ends. **Setting `publicIngress.enabled` back to `false` after a deploy will NOT delete the resources** — `azd`/ARM incremental deployments only stop managing them.

**Default state:** disabled. No public-ingress resources are provisioned.

**Parameter contract** (`publicIngressType` exported from `main.bicep`):

```bicep
publicIngress: {
  enabled: bool                              // master toggle, default false
  backendAppIndex: int?                      // index into containerAppsList; default 0
  frontendHostName: string?                  // e.g., 'app.contoso.com' — required to activate HTTPS
  sslCertSecretId: string?                   // versionless Key Vault secret URI — required to activate HTTPS
  allowedSourceAddressPrefixes: string[]?    // CIDRs allowed to reach :443; empty list = deny-all
  wafMode: ('Prevention' | 'Detection')?     // default 'Prevention'
  wafCustomRules: object[]?                  // merged with OWASP CRS 3.2 managed ruleset
  capacity: object?                          // default { minCapacity: 0, maxCapacity: 2 }
  sslPolicy: object?                         // default Azure baseline
}
```

**Resources deployed when `enabled = true`** (only effective with `networkIsolation`, `deployContainerEnv`, and at least one entry in `containerAppsList`):

| Resource | Purpose |
| --- | --- |
| `Microsoft.Network/networkSecurityGroups` (`nsg-<vnet>-AppGatewaySubnet`) | Deny-all inbound except `GatewayManager` (65200-65535) and `AzureLoadBalancer`. Adds an `AllowHttpsFromAllowedSources` rule on TCP/443 only when `allowedSourceAddressPrefixes` is non-empty. **Port 80 is never opened from the Internet.** |
| `Microsoft.Network/publicIPAddresses` | Standard SKU, Static, zone-redundant when `useZoneRedundancy=true`. |
| `Microsoft.Network/ApplicationGatewayWebApplicationFirewallPolicies` | OWASP CRS 3.2, mode `Prevention` (or `Detection`), `wafCustomRules` merged in. |
| `Microsoft.ManagedIdentity/userAssignedIdentities` | Dedicated UAI for the gateway. |
| `Microsoft.Authorization/roleAssignments` (`Key Vault Secrets User`) | Granted to the AGW UAI on the landing-zone Key Vault when `deployKeyVault=true`. External Key Vaults must be granted manually. |
| `Microsoft.Network/applicationGateways` | WAF_v2 SKU, autoscale 0..2, zone-redundant, attached to the existing `AppGatewaySubnet` (192.168.3.0/27). Backend pool targets the Container App's internal FQDN over HTTPS:443 with `pickHostNameFromBackendAddress=true`. |
| Diagnostic settings | Streamed to the existing Log Analytics workspace (`allLogs` + `AllMetrics`). |

**Two operational states:**

1. **Skeleton mode** (`enabled=true` and either `sslCertSecretId` or `frontendHostName` empty)
   - Gateway exists with a single HTTP:80 listener routed to the backend.
   - NSG denies all Internet inbound (port 80 is never opened by the NSG).
   - The skeleton is **inert**: no client can reach it from the Internet until the operator transitions to live mode.

2. **Live mode** (`enabled=true` with both `sslCertSecretId` and `frontendHostName` set, plus `allowedSourceAddressPrefixes` non-empty)
   - HTTPS:443 listener using the Key Vault certificate (the AGW UAI reads it via `Key Vault Secrets User`).
   - HTTP:80 becomes a permanent HTTP→HTTPS redirect.
   - NSG allows TCP/443 from the supplied source CIDRs only.

**Post-deploy runbook (provider-agnostic DNS + jumpbox ACME):**

1. **Workstation (DNS provider side):** choose your DNS provider/registrar and prepare your hostname (example: `app.contoso.com`). No provider-specific integration is required in this landing zone.
2. **Jumpbox (certificate issuance/import side):** use the built-in ACME client installed by `install.ps1` at `C:\tools\win-acme\wacs.exe` (DNS-01 flow), then import the resulting certificate into the landing-zone Key Vault. The jumpbox MI has `Key Vault Certificates Officer` for this workflow.
3. **Workstation (DNS provider side):** create/update the public DNS A record for the hostname pointing at `PUBLIC_INGRESS_PUBLIC_IP` (deployment output).
4. Capture the **versionless** Key Vault secret URI for the certificate (`https://<kv>.vault.azure.net/secrets/<name>`), then set operator parameters in `main.parameters.json` (or via `azd env set` followed by an edit since `publicIngress` is an aggregate object):
   ```jsonc
   "publicIngress": {
      "value": {
       "enabled": true,
       "frontendHostName": "app.contoso.com",
       "sslCertSecretId": "https://<kv>.vault.azure.net/secrets/<name>",
        "allowedSourceAddressPrefixes": ["203.0.113.0/24"]
      }
    }
    ```
5. Run `azd provision` again. The HTTPS listener, redirect rule, and NSG allow rule are now in place.
6. Validate end-to-end: `curl -v https://app.contoso.com/` should return the Container App's response; `curl -v http://app.contoso.com/` should redirect to HTTPS.

**Teardown:** run `azd down` to remove the entire deployment, or delete the gateway/PIP/WAF policy/NSG/UAI manually. As stated above, flipping `enabled` back to `false` and re-provisioning will **not** delete the resources due to ARM incremental deployment semantics.

**Outputs surfaced by `main.bicep`:**

| Output | Description |
| --- | --- |
| `PUBLIC_INGRESS_ENABLED` | Whether the stack was effectively deployed (also requires `networkIsolation` + `deployContainerEnv` + non-empty `containerAppsList`). |
| `PUBLIC_INGRESS_PUBLIC_IP` | The gateway's public IPv4 address (point your DNS A record at this). |
| `PUBLIC_INGRESS_GATEWAY_RESOURCE_ID` | Application Gateway resource ID. |
| `PUBLIC_INGRESS_NSG_RESOURCE_ID` | NSG attached to the AGW subnet. |
| `PUBLIC_INGRESS_WAF_POLICY_RESOURCE_ID` | WAF policy resource ID (for adding custom rules outside the template). |
| `PUBLIC_INGRESS_IDENTITY_PRINCIPAL_ID` | Principal ID of the AGW UAI (use to grant access to external Key Vaults). |
| `PUBLIC_INGRESS_LIVE` | `true` only when both `sslCertSecretId` and `frontendHostName` are set (live mode). |

In addition, the landing zone now surfaces a small set of outputs that consumers (and this module) depend on: `APP_GATEWAY_SUBNET_RESOURCE_ID`, `VNET_RESOURCE_ID`, `KEY_VAULT_RESOURCE_ID`, `KEY_VAULT_NAME`, `LOG_ANALYTICS_RESOURCE_ID`, and `CONTAINER_APP_INTERNAL_FQDN`.
