This PowerShell script automates the setup and validation of permissions for onboarding customers to the Crayon Azure Cost Control Service. It creates a service principal with the required read-only role assignments across Azure management, billing, reservations, and savings plans. The script supports Enterprise Agreement (EA), Microsoft Customer Agreement (MCA), and Cloud Solution Provider (CSP) environments.
- Current version: 1.2.8
- Script files:
Assign-AzureFinOpsRole.ps1(and an identical.txtcopy for environments that block.ps1downloads) - Author: Crayon (http://www.crayon.com)
The script automatically starts a transcript log at %TEMP%/crayon-onboarding-<timestamp>.log (or the OS temp directory) so that, even if a run exits unexpectedly, there is always a log file you can send to Crayon for diagnosis.
See Release Notes for the full change history.
The following PowerShell modules are required. The script will automatically install them if not present (falls back to CurrentUser scope if system-wide install fails):
- Az.Accounts - Azure authentication and account management
- Az.Reservations - Azure reservations management
- Az.BillingBenefits - Azure savings plans and billing benefits
- Az.Resources - Azure resource management and RBAC
- Az.Billing - Azure billing account management
- Microsoft.Graph.Authentication - Microsoft Graph authentication
- Microsoft.Graph.Applications - Microsoft Graph app registration management
- Microsoft.Graph.Identity.DirectoryManagement - Microsoft Graph directory operations
Note: On bastion hosts or air-gapped networks without internet access, modules must be pre-installed. The script detects this and provides guidance. Azure Cloud Shell is recommended as an alternative since all modules are pre-installed.
- PowerShell 5.1 or PowerShell 7.x (recommended)
- Windows PowerShell, PowerShell Core, or Azure Cloud Shell (recommended)
- Execution Policy set to allow script execution:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser- Local file system access for creating directories and files:
- Windows:
C:\crayon - Linux/macOS:
~/crayon
- Windows:
- Administrative privileges may be required for module installation (script falls back to CurrentUser scope if unavailable)
- Valid Azure subscription with active billing account
- Appropriate agreement type: Enterprise Agreement (EA), Microsoft Customer Agreement (MCA), or Cloud Solution Provider (CSP)
- For EA customers: Enrollment must be onboarded to Azure portal for modern billing API access
The operator running the script needs to be able to (a) create the app registration and (b) assign the Azure roles below. There are two supported paths:
Option A — Global Administrator (recommended)
- Global Administrator role in Microsoft Entra ID
- Elevated access to Azure resources must be enabled:
- Navigate to: Microsoft Entra ID → Properties → "Access management for Azure resources" → Yes
- This grants User Access Administrator at the tenant root (
/), which is what allows the script to assign the tenant-scoped Reservations Reader role (see note below). - Documentation: https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin?tabs=azure-portal
- Important: Remove elevated access immediately after running the script
Option B — Application Administrator (+ Reservations Administrator)
- Application Administrator role in Microsoft Entra ID — this lets the operator create the app registration, but grants no Azure RBAC on its own.
- Application Administrators cannot enable elevated access, so they must be granted the Azure roles the script assigns. One is easy to miss:
-
Reservations Administrator at the tenant scope (
/providers/Microsoft.Capacity) is required as a supplement. -
Reservations are a tenant-level resource with their own RBAC, separate from subscriptions and management groups. Without Reservations Administrator, the app is created and every other role assigns successfully, but the Reservations Reader assignment fails with an authorization error.
-
This role must be granted by a Global Administrator (or someone with User Access Administrator at the tenant level). It can be assigned in the portal (Reservations → select any reservation → Role Assignment → Roles) or via PowerShell:
New-AzRoleAssignment -Scope "/providers/Microsoft.Capacity" -PrincipalId <operator-object-id> -RoleDefinitionName "Reservations Administrator"
-
Why this only affects the Reservations Reader role: all the other roles are assigned at the management group root or at billing scope, which the Global Admin elevated-access path (or an existing User Access Administrator assignment) already covers. The Reservations Reader role is the only one assigned at the separate
/providers/Microsoft.Capacitytenant scope, so it is the single assignment that fails when the operator lacks tenant-level reservation rights.
- User Access Administrator role at the root management group level (granted automatically with elevated Global Admin access)
- Management Group Reader role (minimum) to access management group hierarchy
- Permission to register Azure resource providers (Microsoft.Management, Microsoft.Capacity, Microsoft.BillingBenefits)
For Enterprise Agreement (EA) customers:
- Enterprise Administrator role in the EA portal (ea.azure.com or billing.microsoft.com)
- EA enrollment must be onboarded to Azure portal for modern billing API access
For Microsoft Customer Agreement (MCA) customers:
- Billing Account Owner or Billing Profile Owner role
- Access to Cost Management + Billing in Azure portal
For Cloud Solution Provider (CSP) customers:
- Admin Agent role in Partner Center
- Access to customer's Azure subscriptions
The script requests the following Microsoft Graph scopes:
Application.ReadWrite.All- To create and manage app registrationsDirectory.Read.All- To read directory information
-
Run the script in Azure Cloud Shell (recommended) or a local PowerShell environment.
-
The script will ask about authentication method:
- Option 1: Interactive Browser (default)
- Option 2: Device Code (required for headless/bastion environments)
- Azure Cloud Shell uses existing authentication automatically.
-
The script will prompt for a specific tenant ID (optional — leave empty for default tenant).
-
Billing-first flow: The script fetches and lists all billing accounts, then lets you select one. Agreement type is auto-detected from the selected billing account. Manual agreement type selection only appears as a fallback if billing accounts cannot be accessed (e.g. CSP tenants or missing billing permissions).
-
Pre-flight permission checks: Before creating anything, the script validates all required permissions:
- Root management group access
- Role assignment permissions
- Microsoft Graph permissions
- Billing account access
- Checks for existing
CrayonCloudEconomicsReaderapp registration - Resource provider registration
- Tenant-context check: warns when the active Azure context points at a different tenant than the one you entered (a common cause of cross-tenant onboarding mistakes)
If blocking issues are found, the script exits cleanly without creating any resources.
-
The script will then:
- Create an Azure AD Application and Service Principal (
CrayonCloudEconomicsReader) - Set expiration date for the service principal (default 36 months, customizable)
- Assign the following roles to the service principal:
- Reader (at management group root level)
- Cost Management Reader (at management group root level)
- Reservations Reader (at Microsoft.Capacity provider level)
- Savings Plan Reader (at Microsoft.BillingBenefits provider level)
- Carbon Optimization Reader (at management group root level)
- Reader role to individual savings plans (if any exist)
- Assign billing-specific roles based on agreement type:
- EA: Enrollment Reader role via Billing API
- MCA: Billing Account Reader role via Billing API
- Display a role assignment summary table showing success/failed/skipped status for each role
- Export two CSV files (see Output Files)
- Perform comprehensive validation tests using the created service principal
- Create an Azure AD Application and Service Principal (
The script generates two separate CSV files in the crayon directory:
| File | Contents | Sensitivity |
|---|---|---|
CrayonCloudEconomics-<TenantName>-<Date>.csv |
Tenant ID, tenant name, domain, country code, agreement type, App ID, secret expiry date | Safe to store |
CrayonCloudEconomics-<TenantName>-<Date>-SECRET.csv |
Tenant ID, App ID, client secret, secret expiry date | Sensitive — handle with care |
Note: The tenant name is sanitized for use in the file name. Characters that are illegal in file paths (for example the
/in "Customer A/S") are replaced with-, so export no longer fails on tenants with such names.
Important: The SECRET file contains the client secret credential. Send it separately and securely. Delete it after transfer.
Both files should be securely sent using https://deila.sensa.is to your Crayon representative. Remove the crayon directory after transfer.
A: Azure Cloud Shell (https://shell.azure.com) is the recommended environment. It has all required modules pre-installed, avoids local execution policy issues, and is already authenticated. If you prefer running locally, use PowerShell 7.x on Windows, macOS, or Linux.
A: You need to enable elevated access as a Global Administrator:
- Go to Azure Portal → Microsoft Entra ID → Properties
- Set "Access management for Azure resources" to Yes
- Click Save
- Re-run the script
Remember to set it back to No after the script completes successfully.
A: A previous run (or partial run) already created the app registration. You need to delete it before re-running:
- Go to Azure Portal → Microsoft Entra ID → App registrations
- Search for
CrayonCloudEconomicsReader - Select it and click Delete
- Also check Deleted applications and permanently delete it there
- Re-run the script
A: Yes, but all required modules must be pre-installed. From a machine with internet access:
Save-Module Az.Accounts, Az.Reservations, Az.BillingBenefits, Az.Resources, Az.Billing, Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement -Path C:\ModulesCopy the C:\Modules folder to the bastion host and import them. Alternatively, use Azure Cloud Shell which always has internet access and pre-installed modules.
A:
- Interactive Browser (Option 1): Use this if you're running the script on a machine with a web browser. A browser window will open for you to sign in.
- Device Code (Option 2): Use this if you're on a headless server, bastion host, or remote session without a browser. You'll get a URL and a code to enter on any device with a browser.
A: Select the billing account that corresponds to the Azure environment you want Crayon to get cost data for. Look at the display name, agreement type, and status. Choose an Active account. If you're unsure, ask your Azure billing administrator which enrollment or billing account covers the subscriptions in scope.
A: The script will warn you and ask for confirmation. A deactivated billing account typically means the enrollment has expired or been migrated. If Microsoft migrated your EA to MCA, the old EA enrollment will show as deactivated — select the new MCA account instead.
A: This happens when the billing account's actual agreement type differs from what was expected. The most common cause is Microsoft migrating an EA enrollment to MCA. The script auto-detects the agreement type from the selected billing account, so this should be rare in v1.2.0+.
A: Not necessarily. Provider-scoped roles (like Reservations Reader at /providers/Microsoft.Capacity) can take several minutes to propagate across Azure. As of v1.2.6 the script polls and retries each validation check (management group roles and Reservations Reader) with backoff for up to 5 minutes, returning as soon as the role becomes effective. If propagation takes even longer and the role assignment itself succeeded (shown in the summary table), the validation failure is just a delay. Wait 5–10 minutes and verify manually in the Azure Portal.
Q: The Reservations Reader role assignment FAILED (not just validation) while every other role succeeded — what's wrong?
A: This is almost always a permissions issue, not a propagation delay, and it typically happens when the operator is an Application Administrator rather than a Global Administrator with elevated access.
Reservations are a tenant-level resource with their own RBAC under /providers/Microsoft.Capacity, separate from subscriptions and management groups. To assign the Reservations Reader role there, the operator needs tenant-level reservation rights:
- A Global Administrator who enabled elevated access gets User Access Administrator at the tenant root (
/), which covers/providers/Microsoft.Capacity— so this path just works. - An Application Administrator has no such access and cannot self-elevate. They must be granted the Reservations Administrator role at the tenant scope as a supplement.
Fix: have a Global Administrator (or a User Access Administrator at the tenant level) grant the operator the Reservations Administrator role, then re-run the role assignment. In the portal: Reservations → select any reservation → Role Assignment → Roles. Or via PowerShell:
New-AzRoleAssignment -Scope "/providers/Microsoft.Capacity" -PrincipalId <operator-object-id> -RoleDefinitionName "Reservations Administrator"If the app registration was already created and only Reservations Reader failed, you don't need to start over — just assign Reservations Reader to the service principal directly once the operator has the rights above (see the "missing roles shown in the summary table" question).
See Role Required → Microsoft Entra ID Permissions for the full breakdown of the two operator paths.
A: No. The provider-level Savings Plan Reader role (/providers/Microsoft.BillingBenefits) is only supported on EA billing accounts. For MCA customers, savings plan data is accessible through the Cost Management Reader role, which is always assigned. This is expected behavior.
Q: The script shows "Select billing account to use (1-)" with a blank number and rejects everything I type — what's wrong?
A: This was a bug in versions up to 1.2.6 that affected tenants with exactly one billing account, fixed in v1.2.7. The billing count failed to resolve in Windows PowerShell 5.1, so the picker rejected every entry in an endless loop. Download the latest script (1.2.7 or newer) and re-run. If you can't update immediately, running the same script in Azure Cloud Shell (PowerShell 7) also avoids the issue.
A: By default, 36 months (3 years). The script prompts you to enter a custom duration in months. Choose a value that matches your Crayon agreement length.
A: The service principal is read-only. It can:
- Read subscription and resource metadata (Reader)
- Read cost and usage data (Cost Management Reader)
- Read reservation details (Reservations Reader)
- Read savings plan details (Savings Plan Reader / Reader on individual plans)
- Read carbon optimization data (Carbon Optimization Reader)
- Read billing account information (Enrollment Reader or Billing Account Reader)
It cannot create, modify, or delete any Azure resources, subscriptions, or billing settings.
A: Not without cleanup. The script checks for an existing CrayonCloudEconomicsReader app registration and will block if one exists. Delete the existing app registration first (see the question above about "already exists").
A: Starting with v1.0.9, the script performs pre-flight checks before creating anything. If it fails during pre-flight, nothing was created. If it fails after creating the service principal (during role assignments), the app registration exists but may have incomplete permissions. You can either:
- Delete the app registration and re-run from scratch
- Manually assign the missing roles shown in the summary table
A: This happens when multiple versions of Az modules are loaded in the same PowerShell session. Fix it by:
- Easiest: Close PowerShell completely, open a new terminal, and run this script first before any other commands.
- Clean install:
Get-Module Az* | Remove-Module -Force Uninstall-Module Az -AllVersions -Force Install-Module Az -Force -AllowClobber
- Recommended: Use Azure Cloud Shell, which always has compatible module versions.
A: No. Set "Access management for Azure resources" back to No immediately after the script completes. The service principal's role assignments persist independently of your elevated access.
A: As of v1.2.3, the script always writes a transcript log to your temp directory (%TEMP%/crayon-onboarding-<timestamp>.log on Windows, the equivalent temp path on macOS/Linux). The transcript path is printed near the top of the run. If the script exits unexpectedly, open that log file or send it to your Crayon representative — it captures all output, including errors that may have scrolled past. v1.2.3 also wrapped the tenant-setup steps in error handling, so most previously-silent failures now print a clear message.
A: Use the secure file transfer portal at https://deila.sensa.is. Send both CSV files to your Crayon representative. After confirmed receipt, delete the local crayon directory (Windows: C:\crayon, Linux/macOS: ~/crayon).
A: The script handles this automatically. It fetches your billing accounts and auto-detects the agreement type. If you want to check beforehand:
- Go to Azure Portal → Cost Management + Billing
- Your billing account will show the agreement type (Enterprise Agreement, Microsoft Customer Agreement, etc.)
- If you access Azure through a partner/reseller, you're likely on CSP
A: Enter the duration of your contract with Crayon for the Cost Control service. This sets the expiry date on the service principal's client secret. If unsure, press Enter to use the default of 36 months. You can always create a new secret later if needed.
- Added targeted guidance to the failure handler for the Reservations Reader role — the one role assigned at the tenant-level
/providers/Microsoft.Capacityscope - When it fails with an authorization error (typically because the operator onboarded as Application Administrator rather than Global Administrator), the script now explains why only this role failed and prints the exact
New-AzRoleAssignmentcommands to (1) grant the operator the Reservations Administrator role and (2) re-assign Reservations Reader to the service principal — no need to start over - Reservations are a tenant-level resource with their own RBAC; a Global Admin reaches it via elevated access (User Access Administrator at root
/), but an Application Admin must be supplemented with Reservations Administrator - Documented the two operator paths (Global Administrator vs Application Administrator + Reservations Administrator) in Role Required and added a matching troubleshooting Q&A. Happy-path behavior is unchanged
- Fixed an infinite "Invalid selection" loop that prevented onboarding on tenants with exactly one billing account (the most common case)
- Root cause: PowerShell unwraps a single-element array into a bare object, so
$billingAccountValues.Countreturned$nullin Windows PowerShell 5.1. The bounds check$parsed -le $nullevaluated as$parsed -le 0, rejecting every input including the only valid choice Fetch-BillingAccountsnow always returns an array (@(...)), andSelect-BillingAccountnormalizes its input and uses a cached count for the "Found N" message, the(1-N)prompt, and the validation check- Symptom this fixes:
Found billing account(s)andSelect billing account to use (1-):with a blank count, where no entry was ever accepted
- Replaced the fixed 60-second validation wait (which caused false failures on tenants where RBAC propagation took longer) with a
Wait-ForConditionpolling helper - Validation now waits a short 20s for propagation to begin, then each check (management group roles, Reservations Reader) polls and retries with backoff for up to 5 minutes, returning as soon as the role becomes effective
- The management group role check, previously checked exactly once, now polls too
- A successful
Get-AzReservationreturning 0 reservations is correctly treated as success rather than retried
- Removed the directory-role pre-flight check added in 1.2.4 — the
/me/memberOf-based check produced false negatives for PIM-activated roles, roles assigned via group membership, custom directory roles, and tenants with reducedDirectory.Read.Allscope - The actual
New-AzADServicePrincipalcall is the only reliable source of truth, and its error handler already explains which role is needed if it fails - Net effect: the script no longer blocks Global Admins (or any other valid role-holder) at pre-flight
- Pre-flight verified the operator held a directory role allowing app creation (Global Admin / Application Admin / Cloud Application Admin / Privileged Role Admin) instead of only checking the granted Graph scope
- Service Principal creation now surfaces the underlying insufficient-privileges Graph error verbatim instead of masking it behind a downstream "ObjectId is empty" error
- Note: the pre-flight role check introduced here was removed in 1.2.5 (see above).
- Wrapped
Get-AzResourceProvider,Register-AzResourceProvider, andGet-AzTenantin try/catch with friendly diagnostics (previously a terminating error in any of them killed the script with no message) - Added an automatic transcript that captures all output to
%TEMP%/crayon-onboarding-<timestamp>.logso silent exits always leave evidence - Added a tenant-context mismatch check that warns when the active Azure context points at a different tenant than the one entered (a common cause of cross-tenant onboarding failures)
- Fixed CSV export failure when the tenant display name contains characters illegal in file paths (e.g. "EG A/S", where
/was treated as a directory separator) - Tenant name is now sanitized for filenames; non-alphanumeric characters (except
.and_) are replaced with-
- Increased propagation wait from 20s to 60s before SPN self-check
- Added retry logic with clear propagation-delay messaging for Reservations Reader check (fixes false failures on tenants where provider-scoped roles take longer to propagate)
- Script now fetches and lists billing accounts before asking for agreement type
- Agreement type is auto-detected from the selected billing account, eliminating manual selection and mismatch issues
- Manual agreement type prompt only appears as fallback when billing accounts cannot be accessed (e.g. CSP tenants or missing billing permissions)
- Replaced blind first-billing-account selection with interactive listing of all billing accounts
- Operator now sees every account (ID, display name, agreement type, status) and selects explicitly
- Deactivated account selection triggers a confirmation warning before proceeding
- Fixes onboarding failures on tenants with mixed EA/MCA billing accounts (e.g. post-migration tenants)
- Added pre-flight permission validation that checks all required permissions before creating anything — no more orphaned app registrations from permission failures
- Added agreement type auto-detection with mismatch warning (catches EA-to-MCA migrations)
- Wrapped all role assignments in try/catch with individual error handling
- Added role assignment summary table at the end of execution
- Clear error guidance for every failure mode
- Module install falls back to
CurrentUserscope if system-wide install fails - SPN propagation uses retry loop instead of fixed sleep
- Two separate CSV files: tenant info (safe) and secrets (sensitive) — previously a single file
- CSV always generates even if some role assignments fail
- Added login method selection (Interactive Browser / Device Code)
- Added tenant selection capability
- Aligned Microsoft Graph authentication with Azure authentication method
- Improved secure token handling using
NetworkCredentialwithAsSecureStringparameter - Added role assignments for BillingBenefits provider roles (read-only savings plan access)
- Improved support for Microsoft Customer Agreement (MCA) billing accounts, allowing the script to fetch billing account IDs for both EA and MCA agreements
- Fixed Service Principal propagation time issue by increasing wait time after creating the Service Principal
- Updated
Get-AzAccessTokencalls to use-AsSecureString:$falseto prepare for Az version 14.0.0 breaking changes
- Replaced deprecated AzureAD modules with Microsoft Graph PowerShell SDK
- Removed: AzureAD, AzureAD.Standard.Preview
- Added: Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Identity.DirectoryManagement
- Updated all Azure AD operations to use Microsoft Graph API
- Changed connection method from
Connect-AzureADtoConnect-MgGraphwith appropriate scopes
Feel free to reach out to the authors or the Crayon FinOps Team for any assistance or feedback related to this script.