Microsoft Azure — Integration Setup
This is not the Microsoft Defender for Cloud integration. That one reads Defender's assessments and alerts, which only exist once you have enabled a Defender plan on the subscription. This one reads your Azure Resource Manager configuration directly — storage accounts, managed disks, activity-log export — which every subscription has whether or not Defender was ever turned on. Connect either, both, or neither.
Prerequisites
- An Azure subscription
- Permission to create an app registration in your Entra ID (Azure AD) tenant
- Permission to assign the Reader role on the subscription
- CMDash Essentials tier or above
Step 1: Register an application
- Entra ID → App registrations → New registration.
- Name it
CMDash Config Reader. - Supported account types: Accounts in this organizational directory only.
- Leave Redirect URI blank — this integration uses the client-credentials grant, which has no browser redirect.
- Register, then copy the Application (client) ID and the Directory (tenant) ID.
Step 2: Create a client secret
- Certificates & secrets → New client secret.
- Set an expiry. Note the date — an expired secret fails every sync.
- Copy the secret Value (not the Secret ID). It is shown once.
Step 3: Assign the Reader role
- Subscriptions → your subscription → Access control (IAM) → Add role assignment.
- Role: Reader.
- Assign access to User, group, or service principal, and select
CMDash Config Reader. - Review + assign.
Least privilege: Reader is the narrowest built-in role that can list storage accounts, disks and diagnostic settings. It grants no write access anywhere, and it does not grant access to storage data (blob contents) — that requires a separate data-plane role which this integration neither needs nor requests.
Do not use Contributor or Owner. Nothing here writes.
Step 4: Connect in CMDash
- Navigate to Integrations in your CMDash dashboard.
- Find Microsoft Azure and click Connect.
- Enter:
- Directory (tenant) ID — from Step 1
- Application (client) ID — from Step 1
- Client secret — from Step 2
- Subscription ID — the subscription to inventory. Required.
- Click Save & Test Connection.
Why a per-connection app registration and not CMDash's OAuth app: the Defender for Cloud integration uses one server-wide client credential, which is correct for a single platform tenant. It is wrong here — every customer has their own Entra tenant, their own app registration, and their own role assignment on their own subscription. So the credentials live on the connection.
CMDash refuses to sync without a Subscription ID rather than defaulting to whichever subscription the service principal happens to see. That fallback would measure an unstated population while looking like a successful sync.
The connection test reads the subscription itself, which proves the service principal both authenticated and has a role assignment. A token fetch alone would report healthy for a principal with access to nothing — the most common misconfiguration here, because Steps 1–2 succeed independently of Step 3.
What Syncs
| Check | Azure API | Control it evidences |
|---|---|---|
| Storage account disallows public blob access | Microsoft.Storage/storageAccounts | CC6.6, SC-7, A.5.10 |
| Storage account requires secure transfer (HTTPS) | Microsoft.Storage/storageAccounts | CC6.7, SC-8 |
| Managed disk is encrypted at rest | Microsoft.Compute/disks | SC-28, A.8.24 |
| Subscription activity log is exported | microsoft.insights/diagnosticSettings | AU-2, A.8.15, CC7.2 |
How the numbers are computed
- Every check is tri-state: pass, fail, or unknown. A property Azure did not return, or a resource type the Reader role could not list, is
unknown— never a failure. - Unknown is excluded from both sides of every percentage. A missing permission produces not measured, not 0%.
allowBlobPublicAccessabsent is unknown, not a pass and not a fail. Azure's default for this property has changed across API versions, so guessing either way is a real error: guessingtruefabricates a critical finding, guessingfalsehides one.- An empty diagnostic-settings list is a genuine failure, not an unknown. It means the activity log is not being exported anywhere, so it ages out of the 90-day retention window with no durable copy. A setting only counts if it has both a destination (workspace, storage account, or event hub) and at least one enabled log category.
- Disk encryption: Azure encrypts managed disks with a platform-managed key by default, so this check passes for nearly every disk. A disk returned without an encryption type is recorded as unknown rather than assumed compliant — an assumed pass on an encryption control is exactly the sort of comfortable default an auditor is entitled to distrust.
Troubleshooting
- Connection test fails with 403 but the token is fine: Step 3 was skipped or applied to the wrong subscription. The app registration existing is not the same as it having access.
AADSTS7000215: Invalid client secret provided: the Secret ID was pasted instead of the secret Value. Azure shows both and they look similar.AADSTS700016: Application not found in the directory: the tenant ID belongs to a different directory than the app registration.- Every check went unknown after months of working: the client secret expired. Azure does not warn.
- Sync fails with "missing its
subscriptionId": the Subscription ID field is empty. This is intentional — see Step 4. - Disk count is lower than the portal shows: the 500-disk ceiling truncated the walk. Check the sync log for the warning.
Rate Limits & Sync Behavior
| Setting | Value |
|---|---|
| Sync frequency | Every 4 hours by default (syncInterval is configurable per connection) |
| Auth | Client-credentials grant per sync. Not cached — an ARM token lives about an hour and syncs are 4 hours apart, so a cache would never hit and would only add a way to serve an expired token |
| Pagination | ARM nextLink, followed through the SSRF-guarded transport so a nextLink pointing at an internal address cannot be followed blindly |
| Disk ceiling | 500 per sync; beyond that the walk truncates, encryptionAtRestCoverage is computed over the truncated set, and the sync log carries a warning |
| Rate limit handling | Routed through CMDash's shared token-bucket limiter at 600 req/min, against ARM's ~12,000 reads/hour per subscription |
| Circuit breaker | Repeated failures open the breaker and the sync is skipped |
| Failed sync retry | Job queue retries 3 times with exponential backoff (starting at 30s) — see docs/developers/dead-letter.md |
Credential Rotation
Client secrets expire. Rotate before the expiry date.
- In Azure: App registrations → CMDash Config Reader → Certificates & secrets → New client secret. Both secrets work simultaneously.
- In CMDash: Settings → Integrations → Microsoft Azure → Edit, enter the new secret, Save.
- Confirm a successful sync, then delete the old secret.
Known Limitations
- One subscription per connection. Management groups are not walked. A multi-subscription estate needs one connection per subscription, and coverage numbers are per connection.
- No key-rotation signal. Key Vault key rotation policies live on the Key Vault data plane — a different token audience and a per-key walk. CMDash reports nothing rather than guessing, so
keyRotationCoveragereads as unmeasured for Azure rather than as a number. That is a true statement about what was checked; a made-up pass would not be. - No password-policy signal. Entra password policy is a Microsoft Graph read, a different API surface from Resource Manager. Same treatment: unmeasured, not assumed.
- No network-security-group analysis. Open-ingress auditing is an unbounded walk whose only possible answer is a truncated one, and a truncated "nothing is exposed" reads as a clean result.
- Storage data is never read. The Reader role deliberately grants no data-plane access. CMDash checks the account's public-access setting, not whether any specific blob is reachable.
- No cross-check against Defender for Cloud. If you connect both, they report independently.