> For the complete documentation index, see [llms.txt](https://docs.firefly.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.firefly.ai/detailed-guides/bulk-onboarding/azure.md).

# Azure

## Azure

**Scope: CLI and API only.** This guide covers the `az` CLI and the ARM templates for onboarding Azure subscriptions without the console wizard. Firefly also ships a Terraform module for Azure onboarding, documented separately.

**Check which Firefly site your tenant is on before you deploy.** Both templates have a `fireflySite` parameter with allowed values `firefly.ai` and `eu.firefly.ai`, defaulting to `firefly.ai`. If you deploy with the default for an EU-hosted tenant, the embedded script registers against the wrong region and the integration won't appear in your console.

**Set `location` to match your data residency requirements.** It defaults to `westus2` in both templates and controls where the storage account holding your Azure activity logs is created. Leaving the default for an EU-hosted tenant puts activity logs in a US region — set it to an appropriate European region alongside `fireflySite: eu.firefly.ai`.

### When to use this

Use this guide to bulk onboard Azure subscriptions, drive onboarding from a CI/CD pipeline, or give a security team the whole integration as a reviewable template before it runs. For a single subscription, the console wizard (**Settings > Integrations > Add New > Azure**) is faster.

Azure's flow differs from AWS and Google Cloud: the templates contain a deployment script that authenticates to Firefly and registers subscriptions for you. There's no separate manual registration call at the end — your Firefly access key and secret key are template parameters.

### Choosing a flow

There are two templates in the repository, and they are **not** variants of the same deployment — they differ in scope, architecture, and the permissions Firefly ends up with.

|                      | **Flow 1: Subscription-scoped**                                                  | **Flow 2: Management-group-scoped**                                                   |
| -------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Template             | `azurefireflydeploy.json`                                                        | `azurefireflydeploy-managementgroups.json`                                            |
| Deployment command   | `az deployment sub create`                                                       | `az deployment mg create`                                                             |
| Which subscriptions  | Explicit list in `targetSubscriptions`                                           | Every subscription under the management group, discovered recursively at deploy time  |
| Monitoring resources | One resource group **per subscription** (`firefly-monitoring-<subId>`)           | **One shared** resource group in a hub subscription (`firefly-monitoring-mg-<mgId>`)  |
| Role assignments     | Written into each subscription individually                                      | Written **once at the management group scope**, inherited by every subscription below |
| Firefly custom role  | Created and assigned in **every** target subscription                            | Created and assigned in the **hub subscription only** (see the permission gap below)  |
| Best for             | A known, stable set of subscriptions; tenants with no management group hierarchy | Large estates, whole hierarchies, or customers who add subscriptions regularly        |

**Rule of thumb:** one subscription → the console wizard. A handful of known subscriptions → Flow 1. A hierarchy, or more subscriptions than you want to list by hand → Flow 2.

The two flows aren't mutually exclusive. A common pattern is Flow 2 across the hierarchy for broad inventory, plus Flow 1 targeted at the few subscriptions where full IaC and secret-adjacent discovery is required — see the permission gap under Flow 2.

### Prerequisites

Common to both flows:

* Admin access to the Firefly console (to create the API key pair)
* Azure CLI, authenticated
* Application Administrator or Global Administrator in Entra ID, to create the service principal
* The Entra ID **primary domain** of your tenant — `directoryDomain` is a **required** parameter in both templates with no default
* `git`, and `jq` if you script the parameter file
* These providers registered in the subscription hosting the deployment: `Microsoft.Storage`, `Microsoft.EventGrid`, and `Microsoft.ContainerInstance` (the deployment scripts run as container instances)

Flow 1 additionally needs:

* Owner or Contributor on every subscription in `targetSubscriptions`
* User Access Administrator or Owner on those subscriptions, to create role assignments

Flow 2 additionally needs:

* **Owner or User Access Administrator at the management group scope** — role assignments are written at the management group, not per subscription
* Contributor on the hub subscription named in `subscriptionIdForDeployment`
* All subscriptions in the same Entra ID tenant. If you have more than one tenant, you need a separate service principal, hub subscription, and deployment for each
* Elevated access, if you're targeting the Tenant Root Group — see "Which management group to target" below

### Phase A — Firefly key pair

Create the key pair under **Settings > Users > Create Key Pair**. Copy both values; they are shown once. These go straight into the deployment parameters, so treat the parameter file as a secret from the moment you create it.

### Phase B — Clone and authenticate

```shell
git clone https://github.com/gofireflyio/arm-firefly-azure-onboarding.git
cd arm-firefly-azure-onboarding
```

| File                                       | Purpose                                   |
| ------------------------------------------ | ----------------------------------------- |
| `azurefireflydeploy.json`                  | Flow 1, subscription-scoped template      |
| `azurefireflydeploy-managementgroups.json` | Flow 2, management-group-scoped template  |
| `CreateUIDefinition.json`                  | Azure Portal guided-deployment UI, Flow 1 |
| `CreateUIDefinition-managementgroups.json` | Azure Portal guided-deployment UI, Flow 2 |
| `README.md`                                | Repository documentation                  |

```shell
az login
# multi-tenant: az login --tenant YOUR_TENANT_ID
az account list --output table
```

### Phase C — Service principal

Create it once. What differs between flows is the scope you grant it.

**Flow 1 — scope to the subscription:**

```shell
az ad sp create-for-rbac \
  --name "Firefly-Integration" \
  --role Reader \
  --scopes /subscriptions/YOUR_SUBSCRIPTION_ID
```

**Flow 2 — scope to the management group:**

```shell
az ad sp create-for-rbac \
  --name "Firefly-Integration-MG" \
  --role Reader \
  --scopes "/providers/Microsoft.Management/managementGroups/YOUR_MG_ID"
```

The management-group-scoped grant covers every subscription in the hierarchy through inheritance, so there's no per-subscription role assignment loop in Flow 2. The template writes its own management-group-scope assignments during deployment, so this initial grant is belt-and-braces — you can omit `--role` and `--scopes` entirely and let the template do all of it.

The output contains `appId`, `password`, and `tenant`. The password cannot be retrieved again — capture it now. Then fetch the object ID, a different value from `appId`, which the templates need for role assignments:

```shell
az ad sp show --id "YOUR_APP_ID" --query id --output tsv
# fallback:
az ad sp list --display-name "Firefly-Integration" --query "[0].id" --output tsv
```

| CLI output                  | Template parameter             |
| --------------------------- | ------------------------------ |
| `appId`                     | `servicePrincipalClientId`     |
| `password`                  | `servicePrincipalClientSecret` |
| `id` (from `az ad sp show`) | `servicePrincipalObjectId`     |

**The secret has an expiry date, and the integration fails silently when it lapses.** `az ad sp create-for-rbac` issues a client secret with a finite lifetime — commonly one year, depending on tenant policy. Nothing warns you beforehand; asset collection simply stops and Inventory quietly goes stale. Record the expiry at onboarding and set a reminder. See Credential rotation below.

***

## Flow 1: Subscription-scoped deployment

Use for a known list of subscriptions. The template loops over `targetSubscriptions` and provisions a full, independent monitoring stack into each one.

#### 1. Set subscription context

```shell
az account set --subscription "YOUR_SUBSCRIPTION_ID"
az account show --output table
```

#### 2. Build the parameters file

```json
{
  "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "location":                     { "value": "westus2" },
    "fireflySite":                  { "value": "firefly.ai" },
    "directoryDomain":              { "value": "customer.onmicrosoft.com" },
    "servicePrincipalObjectId":     { "value": "YOUR_SP_OBJECT_ID" },
    "servicePrincipalClientId":     { "value": "YOUR_SP_CLIENT_ID" },
    "servicePrincipalClientSecret": { "value": "YOUR_SP_CLIENT_SECRET" },
    "fireflyAccessKey":             { "value": "YOUR_FIREFLY_ACCESS_KEY" },
    "fireflySecretKey":             { "value": "YOUR_FIREFLY_SECRET_KEY" },
    "targetSubscriptions":          { "value": ["YOUR_SUBSCRIPTION_ID"] },
    "isMultiSubscription":          { "value": false },
    "eventDrivenEnabled":           { "value": true },
    "isProd":                       { "value": false },
    "enforceStorageNetworkRules":   { "value": false },
    "tags": {
      "value": [
        { "tagName": "Environment", "tagValue": "Production" },
        { "tagName": "ManagedBy",   "tagValue": "Firefly" }
      ]
    }
  }
}
```

| Parameter                      | Type         | Template default         | Notes                                                                                                                                                                               |
| ------------------------------ | ------------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `location`                     | string       | `westus2`                | Region for the Firefly monitoring resources. Set it for data residency                                                                                                              |
| `fireflySite`                  | string       | `firefly.ai`             | **Set to `eu.firefly.ai` for EU tenants.** Drives both the API base and the event webhook host                                                                                      |
| `directoryDomain`              | string       | **required, no default** | Entra ID domain. The deployment fails validation without it                                                                                                                         |
| `servicePrincipalObjectId`     | string       | required                 | Object ID, not app ID                                                                                                                                                               |
| `servicePrincipalClientId`     | string       | required                 | `appId`                                                                                                                                                                             |
| `servicePrincipalClientSecret` | securestring | required                 | `password`                                                                                                                                                                          |
| `fireflyAccessKey`             | securestring | required                 | Used by the embedded script to authenticate                                                                                                                                         |
| `fireflySecretKey`             | securestring | required                 | As above                                                                                                                                                                            |
| `targetSubscriptions`          | array        | current subscription     | Every subscription to onboard                                                                                                                                                       |
| `isMultiSubscription`          | bool         | `true`                   | Template defaults to true; set `false` for a single subscription                                                                                                                    |
| `eventDrivenEnabled`           | bool         | `true`                   | Event Grid real-time monitoring                                                                                                                                                     |
| `isProd`                       | bool         | `false`                  | Marks the integration as production                                                                                                                                                 |
| `enforceStorageNetworkRules`   | bool         | `false`                  | Applies network rules to the log storage account, restricted to Firefly egress IPs                                                                                                  |
| `fireflyWebhookUrl`            | string       | empty                    | Overrides the default `https://azure-events.{fireflySite}`. Leave empty unless instructed otherwise                                                                                 |
| `fireflyEips`                  | array        | Firefly egress IPs       | **Fallback only.** A deployment script fetches the live list from `https://api.{fireflySite}/v2/infrastructure/public-nat-ips` and only falls back to this array if that call fails |
| `tags`                         | array        | `[]`                     | Applied to created resources                                                                                                                                                        |

The parameter file holds the service principal secret and both Firefly keys in plaintext. Add it to `.gitignore` before you populate it, and delete it from your workstation once the deployment succeeds.

#### 3. Validate and preview

```shell
az deployment sub validate \
  --location westus2 \
  --template-file azurefireflydeploy.json \
  --parameters @parameters.json

az deployment sub what-if \
  --location westus2 \
  --template-file azurefireflydeploy.json \
  --parameters @parameters.json
```

`what-if` is worth running in front of your security team — it shows exactly what the template will create before anything is committed.

#### 4. Deploy

```shell
az deployment sub create \
  --name firefly-deployment-$(date +%s) \
  --location westus2 \
  --template-file azurefireflydeploy.json \
  --parameters @parameters.json
```

Expect 5–15 minutes. Per subscription in the array, the template creates a resource group (`firefly-monitoring-<subscriptionId>`), a storage account for activity logs, an Event Grid system topic and subscription, custom role definitions, role assignments, and diagnostic settings — then runs the deployment script that registers everything with Firefly.

**Locked-down subscriptions:** the registration step runs as an ARM `deploymentScripts` resource, which provisions its own storage account and container instance behind the scenes. Azure Policy that denies public storage endpoints or mandates private endpoints will block it, and the failure surfaces as "deployment succeeded, script failed" — which looks like a Firefly problem but is a policy problem. Check for such policies before deploying into a regulated subscription.

#### 5. Adding more subscriptions

Grant the service principal Reader on each additional subscription, then list them all in `targetSubscriptions`:

```shell
for SUB in SUBSCRIPTION_ID_2 SUBSCRIPTION_ID_3; do
  az role assignment create \
    --assignee YOUR_SP_APP_ID \
    --role Reader \
    --scope "/subscriptions/${SUB}"
done
```

Set `isMultiSubscription` to `true` and redeploy. All subscriptions must sit in the same Entra ID tenant.

If this loop is getting long, you want Flow 2.

***

## Flow 2: Management-group-scoped deployment

Use for bulk onboarding across a hierarchy. You name one management group; the template discovers every subscription beneath it recursively, including subscriptions in nested child management groups, and registers each one as its own Firefly integration.

#### How this flow is architecturally different

This is not simply "Flow 1 with a wider net" — read this before deploying.

* **Role assignments are written once, at the management group scope,** and inherit downward. Six built-in roles: Reader, Billing Reader, App Configuration Data Reader, Security Reader, **Monitoring Reader**, and **Management Group Reader**. The last two don't appear in Flow 1 at all.
* **Monitoring infrastructure is centralized, not per-subscription.** One resource group (`firefly-monitoring-mg-<managementGroupId>`), one storage account, and one Event Grid system topic are created in the hub subscription you nominate via `subscriptionIdForDeployment`. Every subscription in the hierarchy gets a diagnostic setting pointing at that single shared storage account. Flow 1, by contrast, builds a complete stack in each subscription.
* **A diagnostic setting is also written at the management group scope itself**, capturing management-group-level activity, in addition to the per-subscription ones.
* **Each subscription still becomes its own Firefly integration.** The management group is the deployment scope, not the unit of integration. A 200-subscription hierarchy produces 200 integrations in the console, named after each subscription's display name.

#### Permission gap versus Flow 1 (read this)

The Firefly custom role and the conditional Storage Blob Data Reader assignment are created with `assignableScopes` limited to the hub subscription, and are **only assigned there** — they are not propagated across the management group.

In practice, every subscription other than the hub gets read-level inventory and billing visibility from Firefly, but **not** the elevated permissions the custom role grants (storage account keys, database connection strings, AKS cluster credentials, web app configuration, Redis keys, search service keys, and Log Analytics workspace keys), and **not** the conditional blob access used to read Terraform state objects.

**Consequence:** Terraform state discovery and IaC mapping won't work in non-hub subscriptions under a pure Flow 2 deployment. If you need that, either run Flow 1 additionally against the subscriptions holding your state backends, or reach out to Firefly about it. Don't assume the management-group deployment covers it — set this expectation during a POC rather than after.

#### Which management group to target

Don't reflexively pick the Tenant Root Group. Two things make a lower, more specific management group the better choice in most estates.

`isProd` and `tags` apply to the whole deployment — they're single values, written identically to every subscription the run discovers. A real hierarchy contains production and non-production subscriptions side by side, so one root-level run mislabels a large part of the estate in Firefly and makes your environment filters useless.

The pattern that works is one run per branch: a deployment against the production management group with `isProd: true` and production tags, then a second against the non-production management group with its own values. Both runs can share the same service principal and hub subscription — only the parameter file changes. Split further if tagging conventions differ by business unit.

**The Tenant Root Group needs elevated access.** Deploying at root requires a Global Administrator to enable "Access management for Azure resources" in Entra ID, which grants them User Access Administrator at root, and then to assign the deploying identity there. Without it, the role assignment step fails with an authorization error that never mentions the root group:

```shell
# Global Administrator, once, and reverse it afterwards
az rest --method post \
  --url "https://management.azure.com/providers/Microsoft.Authorization/elevateAccess?api-version=2016-07-01"
```

Have the Global Administrator reverse the elevation once the deployment completes. If a lower management group covers the subscriptions you care about, targeting it avoids this step entirely.

#### 1. Inspect the hierarchy first

Always do this before deploying — it tells you the management group ID to use and exactly how many subscriptions you're about to onboard.

```shell
# list management groups
az account management-group list --output table

# full recursive tree, for visual inspection
az account management-group show \
  --name "YOUR_MG_ID" --expand --recurse --output json
```

Note the ID (`name` field), not the display name — `managementGroupId` expects the ID.

To count the subscriptions you're about to onboard, use the descendants API. A JMESPath query against `children` returns only the direct children of the management group, so on any hierarchy deeper than one level it undercounts — you'd later conclude the deployment failed when it didn't:

```shell
az rest --method get \
  --url "https://management.azure.com/providers/Microsoft.Management/managementGroups/YOUR_MG_ID/descendants?api-version=2021-04-01" \
  --query "value[?type=='Microsoft.Management/managementGroups/subscriptions'].{id:name, name:properties.displayName}" \
  --output table

# just the count
az rest --method get \
  --url "https://management.azure.com/providers/Microsoft.Management/managementGroups/YOUR_MG_ID/descendants?api-version=2021-04-01" \
  --query "length(value[?type=='Microsoft.Management/managementGroups/subscriptions'])"
```

Record that number — it's what you check the deployment against afterward. If the management group contains subscriptions you didn't intend to onboard, move them or pick a lower management group in the tree. There's no exclusion parameter.

#### 2. Choose the hub subscription

`subscriptionIdForDeployment` is required and has no default. It nominates the subscription that will host the shared monitoring resources and run the deployment scripts. Choose one that is:

* long-lived and not scheduled for decommissioning
* not subject to Azure Policy that blocks public storage endpoints or container instances
* the subscription holding your Terraform state backends, if you have a central one — that maximizes what the custom role can reach given the permission gap above

#### 3. Build the parameters file

Note the different `$schema` — a subscription-scoped parameters file won't validate here.

```json
{
  "$schema": "https://schema.management.azure.com/schemas/2019-08-01/managementGroupDeploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "managementGroupId":            { "value": "YOUR_MG_ID" },
    "subscriptionIdForDeployment":  { "value": "YOUR_HUB_SUBSCRIPTION_ID" },
    "location":                     { "value": "westus2" },
    "fireflySite":                  { "value": "firefly.ai" },
    "directoryDomain":              { "value": "customer.onmicrosoft.com" },
    "servicePrincipalObjectId":     { "value": "YOUR_SP_OBJECT_ID" },
    "servicePrincipalClientId":     { "value": "YOUR_SP_CLIENT_ID" },
    "servicePrincipalClientSecret": { "value": "YOUR_SP_CLIENT_SECRET" },
    "fireflyAccessKey":             { "value": "YOUR_FIREFLY_ACCESS_KEY" },
    "fireflySecretKey":             { "value": "YOUR_FIREFLY_SECRET_KEY" },
    "eventDrivenEnabled":           { "value": true },
    "isProd":                       { "value": true },
    "enforceStorageNetworkRules":   { "value": false },
    "tags": {
      "value": [
        { "tagName": "ManagedBy", "tagValue": "Firefly" }
      ]
    }
  }
}
```

Parameters that differ from Flow 1:

| Parameter                     | Type   | Default                  | Notes                                                                                                             |
| ----------------------------- | ------ | ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `managementGroupId`           | string | current management group | The management group ID, not display name. Defaults to the group you deploy into, but set it explicitly           |
| `subscriptionIdForDeployment` | string | **required, no default** | Hub subscription hosting the shared resource group, storage account, Event Grid topic, and the deployment scripts |
| `targetSubscriptions`         | n/a    | n/a                      | **Does not exist in this template.** Subscriptions are discovered, not listed                                     |
| `isMultiSubscription`         | n/a    | n/a                      | **Does not exist in this template.** Multi-subscription is implicit                                               |

All other parameters (`fireflySite`, `directoryDomain`, the service principal trio, the Firefly key pair, `eventDrivenEnabled`, `isProd`, `enforceStorageNetworkRules`, `fireflyWebhookUrl`, `fireflyEips`, `tags`) behave exactly as in the Flow 1 table above.

#### 4. Validate and preview

```shell
az deployment mg validate \
  --management-group-id "YOUR_MG_ID" \
  --location westus2 \
  --template-file azurefireflydeploy-managementgroups.json \
  --parameters @parameters-mg.json

az deployment mg what-if \
  --management-group-id "YOUR_MG_ID" \
  --location westus2 \
  --template-file azurefireflydeploy-managementgroups.json \
  --parameters @parameters-mg.json
```

`--location` is mandatory for management-group-scoped deployments even though the management group itself isn't regional — it sets where deployment metadata is stored.

`what-if` will show the management-group-scope role assignments and the hub-subscription resources, but **not** the per-subscription diagnostic settings or the Firefly integrations — those are created by deployment scripts at runtime and are invisible to `what-if`. If a security team asks why the preview looks smaller than expected, that's why.

#### 5. Deploy

```shell
az deployment mg create \
  --name firefly-mg-$(date +%s) \
  --management-group-id "YOUR_MG_ID" \
  --location westus2 \
  --template-file azurefireflydeploy-managementgroups.json \
  --parameters @parameters-mg.json
```

Expect longer than Flow 1. Both scripts process subscriptions serially, with a deliberate pause between each, so allow roughly 15–30 minutes for a large hierarchy.

**There's a ceiling.** The deployment scripts have a 30-minute timeout and no resume, so a hierarchy large enough to exceed it fails partway with no way to continue from where it stopped. Somewhere in the low hundreds of subscriptions is where this starts to matter. At that scale, split the work by child management group and run one deployment per branch — which is also what you want for `isProd` accuracy.

#### 6. What the deployment scripts actually do

Three scripts run, all as AzurePowerShell deployment scripts in the hub subscription.

1. **IP fetch.** Calls `https://api.{fireflySite}/v2/infrastructure/public-nat-ips` for the live Firefly egress IP list, falling back to the `fireflyEips` array if unreachable.
2. **Diagnostics.** Authenticates as the service principal, walks the management group hierarchy recursively, and writes a diagnostic setting named `firefly-mg-diagnostics-<subId>` into each subscription, covering Administrative, Security, ServiceHealth, Alert, Recommendation, Policy, Autoscale, and ResourceHealth logs — all targeting the shared hub storage account.
3. **Integration.** Authenticates to Firefly, waits 30 seconds for role assignments to propagate, walks the hierarchy again, and registers each subscription:

```
POST /api/account/access_keys/login                 → exchanges the key pair for a token
POST /api/integrations/azure?onConflictUpdate=true   → registers each subscription
```

`onConflictUpdate=true` means re-running the deployment updates existing integrations rather than failing, so redeploys are safe.

Behavior worth knowing:

* **Disabled subscriptions are skipped** silently. If the count in Firefly is lower than the count in the management group, check subscription state first.
* **Integration names are the subscription display names, sanitized.** Anything outside `A-Z a-z 0-9 space _ : . @ / + , -` is replaced with a hyphen, runs of hyphens are collapsed, and leading non-alphanumerics are stripped. A subscription whose name is entirely special characters falls back to its subscription ID. Expect cosmetic name differences between Azure and Firefly for decorated naming conventions.
* **A per-subscription failure does not fail the deployment.** The script logs a warning, adds the subscription to a failed list, and continues — it only errors out if every subscription failed. Always read the script output (see Verify).
* **The Firefly API can return HTTP 200 with a validation error in the body.** The script detects this and counts it as a failure, but the deployment still reports success overall.

#### 7. The auto-discovery caveat: correct expectation setting

The repository README describes "automatic discovery of new subscriptions." That's **partially** true, and it's the most common source of a wrong customer expectation. Be precise:

| When a subscription is added to the hierarchy later | What happens                                                                                                                |
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| RBAC role assignments                               | **Inherited automatically.** Management-group-scope assignments apply to the new subscription immediately, no action needed |
| Diagnostic settings                                 | **Not created.** The diagnostics script only ran at deploy time                                                             |
| Firefly integration / registration                  | **Not created.** The new subscription won't appear in the Firefly console                                                   |

This is **not** the equivalent of AWS StackSet auto-deployment. To pick up subscriptions added after the initial deployment, **re-run the same** `az deployment mg create` command. It's idempotent: `onConflictUpdate=true` updates existing integrations rather than duplicating them, and existing diagnostic settings are updated in place.

Set up either a scheduled redeploy (a pipeline running the same command monthly, or on subscription-vending events) or a documented runbook step in your subscription provisioning process. Raise this during a POC rather than discovering it three months later with a stale inventory.

#### 8. Management-group-specific failure modes

**Missing Management Group Reader is the dangerous one.** If the service principal can't enumerate the hierarchy, the script doesn't fail — it catches the error, logs a warning, and **falls back to onboarding only the hub subscription.** The deployment reports success. One integration appears instead of two hundred, and unless you read the script log the cause is invisible. If the subscription count is wrong, check this first.

**Azure Policy blocking deployment scripts.** The registration and diagnostics steps run as ARM `deploymentScripts`, which provision their own storage account and container instance in the hub subscription. Policy that denies public storage endpoints, mandates private endpoints, or restricts container instances will block them, and the failure surfaces as "deployment succeeded, script failed" — which reads as a Firefly problem when it's a policy problem. Management group hierarchies almost always carry inherited policy, so check the hub subscription's effective policy before deploying:

```shell
az policy assignment list --scope "/subscriptions/YOUR_HUB_SUBSCRIPTION_ID" --output table
```

**Provider registration is per-subscription.** `Microsoft.Storage`, `Microsoft.EventGrid`, and `Microsoft.ContainerInstance` must be registered in the hub subscription. Diagnostic settings additionally require `Microsoft.Insights` in each target subscription — that's usually already registered, but a fresh subscription in the hierarchy may not have it.

***

### What this costs

The integration itself is free. What you pay for is the Azure infrastructure the template creates:

* **Storage account**, holding activity logs. Flow 1 creates one per subscription; Flow 2 creates one shared account. Usually the largest line item, and it scales with activity volume — a busy production subscription costs more than a dormant one.
* **Event Grid system topic and subscription**, only when `eventDrivenEnabled` is true. Priced per operation with the first 100,000 operations each month free, so this is typically negligible.
* **Deployment scripts**, which run a container instance and a temporary storage account during deployment only. Cents, once.
* **Diagnostic settings** carry no charge themselves; the cost is the storage they write to.

For most customers the whole thing lands in the low tens of dollars per month. Activity log volume is the variable, and a lifecycle management policy on the storage account is the lever if it grows. Flow 2 is generally cheaper than Flow 1 across the same number of subscriptions, since there's one storage account rather than many.

### Verify

**Flow 2 first step: read the deployment script output.** This is where partial failures live, and neither Azure nor Firefly surfaces them elsewhere.

```shell
az deployment mg show \
  --management-group-id "YOUR_MG_ID" \
  --name DEPLOYMENT_NAME \
  --query "properties.outputs"

# the integration script's own log, in the hub subscription
az deployment-scripts list \
  --query "[?contains(name,'firefly-integration')].{name:name, state:provisioningState}" -o table
az deployment-scripts show-log --resource-group firefly-monitoring-mg-YOUR_MG_ID --name SCRIPT_NAME
```

The script prints an `=== INTEGRATION SUMMARY ===` block with total subscriptions found, successful integrations, and a named list of failures with reasons. Compare the total against your Step 1 hierarchy count.

**If some subscriptions failed**, the fix depends on the reason given. Transient API errors and rate limiting usually clear on a straight re-run of the same deployment, which is safe and idempotent. Authorization errors on specific subscriptions normally mean the management-group role assignments hadn't finished propagating when that subscription was processed, and a re-run resolves those too. A subscription that fails repeatedly is usually blocked by something local to it, such as policy or a disabled state, and is faster to onboard with a targeted Flow 1 run than to keep retrying the whole hierarchy.

Azure-side checks:

```shell
# Flow 1: one resource group per subscription
az group list --query "[?contains(name,'firefly-monitoring')].{Name:name, Location:location}" -o table

# Flow 2: one shared resource group in the hub subscription
az group show --name firefly-monitoring-mg-YOUR_MG_ID -o table
az resource list --resource-group firefly-monitoring-mg-YOUR_MG_ID -o table

# Flow 2: management-group-scope role assignments
az role assignment list \
  --assignee YOUR_SP_OBJECT_ID \
  --scope "/providers/Microsoft.Management/managementGroups/YOUR_MG_ID" \
  --output table

# Flow 2: spot-check diagnostic settings in a non-hub subscription
az monitor diagnostic-settings subscription list \
  --subscription ANOTHER_SUB_ID \
  --query "[?contains(name,'firefly')]"
```

Expected role assignments:

| Role                                                    | Flow 1 (per subscription) | Flow 2 (at management group, inherited) |
| ------------------------------------------------------- | ------------------------- | --------------------------------------- |
| Reader                                                  | yes                       | yes                                     |
| Billing Reader                                          | yes                       | yes                                     |
| Security Reader                                         | yes                       | yes                                     |
| App Configuration Data Reader                           | yes                       | yes                                     |
| Monitoring Reader                                       | no                        | yes                                     |
| Management Group Reader                                 | no                        | yes                                     |
| Storage Blob Data Reader (conditional, Terraform state) | yes                       | hub subscription only                   |
| Firefly custom role                                     | yes                       | hub subscription only                   |

Then in the console: **Settings > Integrations > Azure**, confirm the expected number of subscriptions is listed and connected, and check **Inventory** filtered to Azure after 10–15 minutes. If event-driven is on, change a test resource and confirm it surfaces within a couple of minutes.

For a large hierarchy, counting integrations in the console by eye is unreliable. Pull the count from the API instead, using the same key pair the deployment used:

```shell
TOKEN=$(curl -s -X POST "https://prodapi.firefly.ai/api/account/access_keys/login" \
  -H "Content-Type: application/json" \
  -d '{"accessKey":"YOUR_ACCESS_KEY","secretKey":"YOUR_SECRET_KEY"}' | jq -r .access_token)

curl -s "https://prodapi.firefly.ai/api/integrations/azure" \
  -H "Authorization: Bearer $TOKEN" | jq 'length'
```

Substitute `eu.firefly.ai` for EU tenants. That number should match the subscription count from Step 1, less any disabled subscriptions.

### Credential rotation

Applies to both flows. Check when the secret expires, at onboarding and periodically thereafter:

```shell
az ad app credential list --id YOUR_SP_CLIENT_ID \
  --query "[].{keyId:keyId, start:startDateTime, end:endDateTime}" --output table
```

To rotate before expiry, add a new secret, redeploy the template with it, then remove the old one:

```shell
# 1. new secret
az ad app credential reset --id YOUR_SP_CLIENT_ID --append --years 1

# 2. update servicePrincipalClientSecret in the parameter file, then redeploy
#    Flow 1:
az deployment sub create --name firefly-rotate-$(date +%s) --location westus2 \
  --template-file azurefireflydeploy.json --parameters @parameters.json
#    Flow 2:
az deployment mg create --name firefly-rotate-$(date +%s) \
  --management-group-id "YOUR_MG_ID" --location westus2 \
  --template-file azurefireflydeploy-managementgroups.json --parameters @parameters-mg.json

# 3. remove the superseded secret
az ad app credential delete --id YOUR_SP_CLIENT_ID --key-id OLD_KEY_ID
```

The redeploy is safe — `onConflictUpdate=true` updates existing integrations in place rather than creating duplicates. Use `--append` on the reset so the old secret keeps working until the new one is live; otherwise collection stops between steps 1 and 2.

**Flow 2 note:** rotation redeploys the whole hierarchy, so it doubles as a resync — any subscriptions added since the last deployment get picked up at the same time.

### Troubleshooting

| Symptom                                                                 | Likely cause                                                                                                                                           |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Deployment succeeds, nothing in Firefly                                 | `fireflySite` wrong for the tenant's region, or an incorrect Firefly key pair. Check the deployment script output                                      |
| **Flow 2: only the hub subscription onboarded**                         | Service principal lacks Management Group Reader. The script fell back silently. The most common management-group failure                               |
| **Flow 2: fewer integrations than subscriptions**                       | Disabled subscriptions are skipped, or there were per-subscription failures. Read the `INTEGRATION SUMMARY` block                                      |
| **Flow 2: integration names differ from Azure**                         | Name sanitization stripped unsupported characters. Cosmetic                                                                                            |
| **Flow 2: new subscription not appearing**                              | Expected — registration isn't automatic. Re-run the deployment                                                                                         |
| Template validation fails on the management-group template              | Wrong `$schema` in the parameter file, or `subscriptionIdForDeployment` / `directoryDomain` missing. Both are required with no default                 |
| `--location` missing error                                              | Mandatory on `az deployment mg create` even though management groups aren't regional                                                                   |
| Worked for months, then stopped                                         | Service principal secret expired — see Credential rotation                                                                                             |
| "Deployment succeeded, script failed"                                   | Azure Policy blocking the `deploymentScripts` storage account or container instance in the hub subscription                                            |
| Service principal creation fails                                        | Missing Application Administrator, tenant app registration limit, or a name collision                                                                  |
| Insufficient permissions on deploy                                      | Flow 2 needs Owner or User Access Administrator **at the management group**, not just on subscriptions                                                 |
| Authorization error deploying at the Tenant Root Group                  | Elevated access not granted — see "Which management group to target"                                                                                   |
| Activity logs stop arriving after enabling `enforceStorageNetworkRules` | The storage firewall is denying Azure Monitor. Confirm the storage account permits trusted Azure services and that the Firefly IP allowlist is current |
| Role assignment errors                                                  | `servicePrincipalObjectId` populated with the app ID instead of the object ID                                                                          |
| Terraform state not discovered outside the hub subscription (Flow 2)    | Expected — see the permission gap section                                                                                                              |
| Event-driven not firing                                                 | Event Grid system topic or subscription missing, or the webhook endpoint doesn't match the tenant's site                                               |

```shell
# Flow 1
az deployment sub show --name DEPLOYMENT_NAME --query "properties.error"
az deployment operation sub list --name DEPLOYMENT_NAME \
  --query "[?properties.provisioningState=='Failed']"

# Flow 2
az deployment mg show --management-group-id "YOUR_MG_ID" --name DEPLOYMENT_NAME --query "properties.error"
az deployment operation mg list --management-group-id "YOUR_MG_ID" --name DEPLOYMENT_NAME \
  --query "[?properties.provisioningState=='Failed']"
```

### Removal

Remove the integrations in the Firefly console first, then clean up Azure.

**Flow 1:**

```shell
az group delete --name firefly-monitoring-YOUR_SUBSCRIPTION_ID --yes --no-wait
az role assignment delete --assignee YOUR_SP_OBJECT_ID --scope /subscriptions/YOUR_SUBSCRIPTION_ID
```

**Flow 2:**

```shell
# shared monitoring resource group in the hub subscription
az group delete --name firefly-monitoring-mg-YOUR_MG_ID --yes --no-wait

# management-group-scope role assignments
az role assignment delete --assignee YOUR_SP_OBJECT_ID \
  --scope "/providers/Microsoft.Management/managementGroups/YOUR_MG_ID"

# management-group-level diagnostic setting
az monitor diagnostic-settings delete \
  --resource "/providers/Microsoft.Management/managementGroups/YOUR_MG_ID" \
  --name DIAGNOSTIC_NAME

# per-subscription diagnostic settings are NOT removed by deleting the resource group
for SUB in $(az rest --method get \
  --url "https://management.azure.com/providers/Microsoft.Management/managementGroups/YOUR_MG_ID/descendants?api-version=2021-04-01" \
  --query "value[?type=='Microsoft.Management/managementGroups/subscriptions'].name" -o tsv); do
  az monitor diagnostic-settings subscription delete \
    --subscription "$SUB" --name "firefly-mg-diagnostics-$SUB" --yes
done
```

Then, common to both:

```shell
az ad sp delete --id YOUR_SP_CLIENT_ID
az role definition list --custom-role-only true -o table
```


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.firefly.ai/detailed-guides/bulk-onboarding/azure.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
