- name
- azure-tenant-isolation
- description
- Multi-tenant Azure CLI and AZD isolation for concurrent terminal sessions. Index-file driven, bare `az` CLI commands only — no wrapper scripts, no PowerShell modules. Mandatory two-layer guard: per-tenant `AZURE_CONFIG_DIR` is the foundation, and `az account show` assertion is the extra gate that verifies tenant + subscription before destructive operations. USE FOR: az login, azd up, azd deploy, az account set, Azure subscription, Azure tenant, AZURE_CONFIG_DIR, AZD_CONFIG_DIR, multi-tenant, switch tenant, deploy to Azure, Bicep deploy, az deployment, azd auth, ChainedTokenCredential, Azure identity, verify subscription, confirm tenant, tenant index, prevent cross-tenant deployment. DO NOT USE FOR: provisioning Azure resources (use azd-patterns), deploying Foundry agents (use foundry-hosted-agents), deploying Citadel gateway (use citadel-hub-deploy).
- metadata
- {"version":"1.2.1"}
# Multi-Tenant Azure CLI & AZD Isolation
Run multiple `az` / `azd` workflows against different Azure tenants and
subscriptions **at the same time** without crossing wires.
This skill ships **two files only**: this document and a JSON schema example.
There are **no wrapper scripts, no PowerShell modules, no helper functions**.
Every Azure operation in this skill is a **literal `az` (or `azd`) CLI
command**. The only shell-specific bits are the unavoidable env-var
one-liners (`export VAR=…` on Unix, `$env:VAR=…` on Windows).
---
## Problem
`az` and `azd` keep session state (tokens, active subscription, environments)
in a **single shared directory** (`~/.azure` and `~/.azd` by default). When
two terminals or scripts target different tenants concurrently they collide:
- `az login --tenant X` in terminal A overwrites the token used by terminal B.
- `az account set -s <sub>` mutates **global** state — every shell sees it.
- `azd env select` in one shell leaks into another.
- A subprocess that forgets `AZURE_CONFIG_DIR` silently hits the wrong tenant.
This is the #1 cause of "I deployed to the wrong subscription" incidents.
### Concrete failure
Three concurrent shells targeting three different aliases — without
isolation, terminal C's `az account set` silently rewrites the active
subscription that terminal A is about to deploy with:
```
Terminal A (prod) Terminal B (dev) Terminal C (partner)
───────────────── ───────────────── ───────────────────
az login --tenant <prod>
az login --tenant <dev>
az login --tenant <partner>
az account set -s partner-shared
az account show ← shows partner-shared (!!)
azd up ← deploys prod env to partner sub
```
With per-terminal `AZURE_CONFIG_DIR` set to `~/.azure-tenants/{prod,dev,partner}`,
each shell has its own token cache and active sub — the writes in C never
reach A.
---
## Design — two layered guards
Tenant isolation here is built from **two stacked guards**. They are
**complementary**, not alternatives.
```
+-----------------------------------------+
per-tenant | AZURE_CONFIG_DIR=~/.azure-tenants/prod | ← FOUNDATION
isolation | AZD_CONFIG_DIR =~/.azd-tenants/prod | never replaced
+-----------------------------------------+
|
v
+-----------------------------------------+
subscription | az account show --query tenantId -o tsv| ← EXTRA GATE
assertion via | az account show --query name -o tsv| compare → exit 1
az CLI | | on mismatch
+-----------------------------------------+
|
v
+-----------------------------------------+
| destructive op (azd up, az deployment) |
+-----------------------------------------+
```
**The assertion does NOT replace the env-var setup.** Both must be present
before any destructive operation. The env vars provide isolation; the
assertion catches drift inside an isolated config dir (e.g. a stale
default subscription, or another shell that ran `az account set` against
the same config dir).
---
## Mandatory rules
These are non-negotiable. Every shell, every script, every agent session
that touches Azure must follow them.
1. **Per-tenant `AZURE_CONFIG_DIR` and `AZD_CONFIG_DIR` are mandatory.**
Set them before the first `az` / `azd` command in every new shell.
They are the foundation guard and are **never** replaced by anything
downstream (assertions, `--subscription` flags, etc.).
2. **Always verify subscription before destructive operations.** Run the
`az account show --query tenantId/name -o tsv` assertion immediately
before `azd up`, `azd deploy`, `az deployment ... create`,
`az group create`, image updates, or any `az ... delete`. The
assertion is the second guard — it does **not** replace rule 1.
3. **Never `az account set` without `AZURE_CONFIG_DIR` set first.**
Without isolation, you mutate the global default subscription that
every other shell on the machine reads.
4. **Never `az login` without `--tenant <id>`.** A bare `az login` opens
the browser picker and may pick the wrong tenant — silently. Always
pass `--tenant <id>`. Same goes for `azd auth login --tenant-id <id>`.
**AND**: `azd` has its own auth chain — `az login` alone does NOT
satisfy `azd ai agent show` / `azd deploy`, even with `AZD_CONFIG_DIR`
set. Run **both** logins per shell:
```bash
az login --tenant "$TENANT_ID"
az account set --subscription "$DEFAULT_SUB" # MANDATORY for multi-sub tenants — see rule 4a
azd auth login --tenant-id "$TENANT_ID"
```
4a. **Multi-sub tenants: `az login --tenant <id>` defaults to whichever
sub was last touched, NOT to `default_subscription`.** When a single
tenant covers multiple subscriptions (e.g., `acme-prod-east` and
`acme-prod-west`), `az login --tenant <id>` populates the token cache
with all of them and silently leaves the active subscription on
whatever was set last in the global cache.
**🛑 DO NOT auto-switch to `default_subscription` blindly.** The
`default_subscription` field in the index file is a **hint**, not a
rule the tooling should silently enforce — a real SE may have run
`az account set --subscription <other-allowed-sub>` immediately
before launching the agent, and your bootstrap script should respect
that intent. Doing otherwise overrides the user's explicit choice
and re-creates the exact bug this skill exists to prevent
(verified live in the 2026-05-28 agentic-loop bootstrap.sh
retrospective).
**DO assert membership in `allowed_subscriptions`** instead. The
correct flow is:
1. After `az login --tenant <id>`, read `ACTUAL_SUB = az account
show --query name -o tsv`.
2. If `ACTUAL_SUB` ∈ `allowed_subscriptions`, accept it. Done.
3. If `ACTUAL_SUB` ∉ `allowed_subscriptions`, **fail loud** with a
message listing the allowed values; do NOT silently switch.
Only set `default_subscription` explicitly when `ACTUAL_SUB` is
empty (first login) or unknown.
Fast check after login:
```bash
az account show --query "{tenant:tenantId, sub:name}" -o table
# Verify sub is one of `allowed_subscriptions` for this alias;
# if not, decide explicitly with: az account set --subscription <one-of-allowed>
```
> **🔴 DO NOT** let `az account set` auto-switch to a subscription outside `allowed_subscriptions`. The default behavior after `az login --tenant` is to activate whichever sub was last touched — verify with `az account show` and reject if not in the whitelist.
See § Assertion variants — strict vs whitelist for the canonical
membership-check snippet.
5. **The index file is personal.** It lists your tenant ids. Gitignore
`~/.azure-tenants/` and `~/.azd-tenants/` globally. Never commit them.
6. **Subprocess inherits env, not isolation state.** Set
`AZURE_CONFIG_DIR` / `AZD_CONFIG_DIR` in the **parent** before
spawning anything that calls `az` / `azd` (Python `subprocess`, Node
`child_process`, `azure.yaml` hooks, GitHub Actions steps, …).
7. **Application code uses `ChainedTokenCredential`, never API keys.**
Keys bypass the `AZURE_CONFIG_DIR` indirection and break the
isolation guarantees.
### Agent preflight (Copilot CLI / automated sessions)
Before running **any** `az` / `azd` command in a session:
1. **CHECK** that `AZURE_CONFIG_DIR` (and `AZD_CONFIG_DIR` if `azd` is
involved) is already set in the current shell.
2. **If NOT set → STOP.** Ask the user which alias from the index this
session targets. Do not guess. Do not fall back to `~/.azure`.
3. **If set → check if token is still valid** with `az account show`.
- **If `az account show` succeeds** → verify tenantId matches the
expected alias. If it does, the token is valid — **do NOT re-login.**
- **If `az account show` fails** (exit code ≠ 0) → token expired.
Only THEN prompt login:
```bash
az login --tenant "$TENANT_ID"
az account set --subscription "$DEFAULT_SUB"
azd auth login --tenant-id "$TENANT_ID" # if azd is involved
```
4. **Also check `azd auth`** if the session will use `azd`:
`azd auth login --check-status`. If "Not logged in", prompt
`azd auth login --tenant-id "$TENANT_ID"`.
5. Only then proceed.
> ⚠️ **Never force `az login` when the token is still valid.** `az login`
> opens a browser (or device-code prompt) which blocks automated sessions.
> `az account show` is a zero-cost check — use it every time before
> deciding whether login is needed.
### Tenant index = single source of truth
Rule 1 above requires per-tenant `AZURE_CONFIG_DIR` / `AZD_CONFIG_DIR`.
The next section describes the JSON index file that holds the
alias→tenant_id+subscription mapping these env vars are derived from.
---
## The tenant index file
Instead of hard-coding tenant ids in shells, scripts, or docs, keep a small
JSON file describing every tenant you work with. By default it lives at:
- `$env:AZURE_TENANT_INDEX` if set
- otherwise `~/.azure-tenants/index.json`
This file is **personal data** — it lists the tenant ids you work with and
their friendly aliases. **Gitignore it. Never commit it.** A starter copy
lives in [`references/index.example.json`](references/index.example.json).
Schema (**structural excerpt** — the canonical 2-tenant example with full
field coverage lives in [`references/index.example.json`](references/index.example.json);
copy from there, don't retype from the snippet below):
```json
{
"version": 1,
"default_alias": "prod",
"tenants": {
"prod": {
"tenant_id": "00000000-0000-0000-0000-000000000001",
"description": "Production tenant",
"config_dir": null,
"azd_config_dir": null,
"default_subscription": "acme-prod",
"allowed_subscriptions": ["acme-prod"]
}
}
}
```
| Field | Type | Meaning |
|-------|------|---------|
| `version` | int | Schema version. Currently `1`. |
| `default_alias` | string | Alias used when none is specified. |
| `tenants.<alias>.tenant_id` | string | Azure AD tenant GUID. |
| `tenants.<alias>.description` | string | Human note (free text). |
| `tenants.<alias>.config_dir` | string\|null | Override for `AZURE_CONFIG_DIR` (used by `az`). `null` → derive `~/.azure-tenants/<alias>`. **`~` is NOT expanded automatically by JSON readers — consumers must expand it themselves** (see "How to read values from the index" below). |
| `tenants.<alias>.azd_config_dir` | string\|null | Override for `AZD_CONFIG_DIR` (used by `azd`). `null` → derive `~/.azd-tenants/<alias>`. Same `~`-expansion caveat as `config_dir`. **Keep this in lock-step with `config_dir`** — overriding one without the other splits the alias's two halves into different folders, which is almost never what you want. |
| `tenants.<alias>.default_subscription` | string | Subscription name (or id) passed to `az account set` after login. |
| `tenants.<alias>.allowed_subscriptions` | string[] | Whitelist of subscription names or IDs (exact membership test — see "Assertion variants" below). Empty/missing → only `default_subscription` is accepted, by name or ID. A non-empty list does not implicitly include the default hint. |
### Bootstrap — manual, no scripts
Unix:
```bash
mkdir -p ~/.azure-tenants ~/.azd-tenants
curl -fsSL -o ~/.azure-tenants/index.json \
https://raw.githubusercontent.com/aiappsgbb/awesome-gbb/main/skills/azure-tenant-isolation/references/index.example.json
$EDITOR ~/.azure-tenants/index.json # replace placeholder GUIDs / sub names
```
Windows (PowerShell):
```powershell
New-Item -ItemType Directory -Force "$env:USERPROFILE\.azure-tenants" | Out-Null
New-Item -ItemType Directory -Force "$env:USERPROFILE\.azd-tenants" | Out-Null
Invoke-WebRequest `
-Uri 'https://raw.githubusercontent.com/aiappsgbb/awesome-gbb/main/skills/azure-tenant-isolation/references/index.example.json' `
-OutFile "$env:USERPROFILE\.azure-tenants\index.json"
notepad "$env:USERPROFILE\.azure-tenants\index.json"
```
Both `~/.azure-tenants/<alias>/` and `~/.azd-tenants/<alias>/` are created
on demand the first time you `az login` / `azd auth login` against them, so
you don't have to pre-create per-alias subfolders. The two top-level
folders above just hold the index file and serve as the parent for those
auto-created per-alias dirs.
Make sure your global `.gitignore_global` (or repo-level `.gitignore`)
excludes both `.azure-tenants/` and `.azd-tenants/` so the files (and
tokens!) never land in a repo.
---
## Canonical `az` CLI flow
All commands below are bare `az` invocations. The only shell-specific lines
are the env-var exports (which cannot be wrapped).
### How to read values from the index
The skill ships **no wrapper scripts**, but reading values out of a JSON
file is a pure read — not a wrapper — and it's needed every time you set
up a shell. Use the platform's built-in JSON reader:
Ver en GitHub