#!/usr/bin/env bash
# Archera onboarding for Azure
#
# This script grants Archera (https://archera.ai) the access it needs to read your
# Azure usage and commitment data, and to manage Reservations / Savings Plans on
# your behalf. It will:
# 1. Provision the Archera enterprise app in your tenant (one-time consent).
# 2. Create a tenant-scope custom role with read-only billing/usage permissions.
# 3. Create a resource group and storage account to hold cost exports.
# 4. Assign the custom role + a few built-in Azure roles to the Archera app.
#
# You'll be prompted to:
# - Confirm your tenant ID and the subscription that will host the storage
# account ("management subscription").
# - Pick which subscriptions to onboard from the eligible list.
# - Approve resource creation before anything is provisioned.
#
# Prerequisites:
# - Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli
# - whiptail (preinstalled on most Linux; macOS: `brew install newt`)
# - You must be signed in: `az login --tenant <your-tenant-id>`
# - Your account needs:
# * Owner (or equivalent) on the management subscription
# * Permission to create custom roles at tenant root (User Access
# Administrator at the root management group — typically requires the
# "Access management for Azure resources" toggle in Microsoft Entra ID).
#
# Supported tenant types: direct subscription onboarding only (Pay-As-You-Go,
# Enterprise Agreement, CSP, MSDN/DevTest). Microsoft Customer Agreement billing
# account onboarding is not supported by this script.
#
# Questions: contact your Archera support representative.
set -euo pipefail
main() {
# Dependencies
command -v az >/dev/null || { echo "az CLI not installed (https://learn.microsoft.com/en-us/cli/azure/install-azure-cli)"; exit 1; }
command -v whiptail >/dev/null || { echo "whiptail not installed (install via 'brew install newt' on macOS or 'apt-get install whiptail' on Linux)"; exit 1; }
# User inputs and validation
read -rp "Tenant ID: " TENANT_ID
read -rp "Management subscription ID: " MANAGEMENT_SUB_ID
read -rp "Azure region [eastus]: " REGION
REGION="${REGION:-eastus}"
validate_inputs
# Pin az session to the management subscription
az account set --subscription "$MANAGEMENT_SUB_ID"
# Default resource names (recommended)
SUFFIX="${MANAGEMENT_SUB_ID%%-*}"
ROLE_NAME="archerarole${SUFFIX}"
RG="archeraresource${SUFFIX}"
STORAGE="archerastorage${SUFFIX}"
STORAGE_SCOPE="/subscriptions/${MANAGEMENT_SUB_ID}/resourceGroups/${RG}/providers/Microsoft.Storage/storageAccounts/${STORAGE}"
# Create new or fetch existing Archera.ai Enterprise Application
echo "Resolving Archera enterprise application..."
ARCHERA_APP_CLIENT_ID="f6e3844b-18dc-479d-ad28-4359dd5b7286"
ARCHERA_APP=$(az ad sp show --id "$ARCHERA_APP_CLIENT_ID" --query id -o tsv 2>/dev/null \
|| az ad sp create --id "$ARCHERA_APP_CLIENT_ID" --query id -o tsv)
[[ -n "$ARCHERA_APP" ]] || { echo "Could not resolve Archera SP. Ensure admin consent is granted in tenant $TENANT_ID."; exit 1; }
# Select eligible subscriptions
select_subscriptions
# Show full plan and ask for one approval before any provisioning
echo
echo "===================================================================="
echo " About to apply the following changes:"
echo "===================================================================="
echo " Tenant ID: $TENANT_ID"
echo " Management subscription: $MANAGEMENT_SUB_ID"
echo " Region: $REGION"
echo " Custom role: $ROLE_NAME (tenant scope)"
echo " Resource group: $RG"
echo " Storage account: $STORAGE"
echo " Custom role assigned to subscriptions:"
printf " - %s\n" "${SUBSCRIPTIONS[@]}"
echo " Built-in roles assigned to Archera SP:"
echo " - Reader and Data Access, Storage Blob Data Reader, User Access Administrator (storage scope)"
echo " - Reservation Reader / Purchaser (capacity + management sub)"
echo " - Savings Plan Reader / Purchaser (billingbenefits + management sub)"
echo " - Advisor Recommendations Contributor (advisor scope)"
echo "===================================================================="
confirm "Proceed?"
# Register resource providers
echo "Registering Microsoft.Storage provider (may take ~15 seconds)..."
az provider register --namespace Microsoft.Storage --wait
echo "Registering Microsoft.CostManagementExports provider (may take ~15 seconds)..."
az provider register --namespace Microsoft.CostManagementExports --wait
# Custom role and per-subscription assignment
create_custom_role
for sub_id in "${SUBSCRIPTIONS[@]}"; do
assign_role "$ROLE_NAME" "/subscriptions/${sub_id}"
done
# Resource group and storage account
az group create --name "$RG" --location "$REGION"
echo "Creating storage account '$STORAGE' (this may take up to 30 seconds)..."
local sa_output sa_rc=0
sa_output=$(az storage account create \
--name "$STORAGE" --resource-group "$RG" --location "$REGION" \
--sku Standard_LRS --kind StorageV2 \
--min-tls-version TLS1_2 \
--allow-blob-public-access false \
--require-infrastructure-encryption true \
--encryption-services blob file 2>&1) || sa_rc=$?
echo "$sa_output"
if [[ $sa_rc -ne 0 ]]; then
if grep -q -E "msrestazure|No module named" <<< "$sa_output"; then
echo
echo "This is a known issue with the 'storage-preview' az extension."
echo "Fix: az extension remove --name storage-preview"
echo "Then re-run this script."
fi
exit "$sa_rc"
fi
# Storage role assignments
echo "Assigning roles (this may take a moment)..."
READER_AND_DATA_ACCESS_ROLE_ID="c12c1c16-33a1-487b-954d-41c89c60f349"
STORAGE_BLOB_DATA_READER_ROLE_ID="2a2b9908-6ea1-4ae2-8e65-a410df84e7d1"
USER_ACCESS_ADMIN_ROLE_ID="18d7d88d-d35e-4fb5-a5c3-7773c20a72d9"
assign_role "$READER_AND_DATA_ACCESS_ROLE_ID" "$STORAGE_SCOPE"
assign_role "$STORAGE_BLOB_DATA_READER_ROLE_ID" "$STORAGE_SCOPE"
assign_role "$USER_ACCESS_ADMIN_ROLE_ID" "$STORAGE_SCOPE"
# Reservations role assignments
RESERVATION_SCOPE="/providers/Microsoft.Capacity"
RESERVATION_READER_ROLE_ID="582fc458-8989-419f-a480-75249bc5db7e"
RESERVATION_PURCHASER_ROLE_ID="f7b75c60-3036-4b75-91c3-6b41c27c1689"
assign_role "$RESERVATION_READER_ROLE_ID" "$RESERVATION_SCOPE"
assign_role "$RESERVATION_PURCHASER_ROLE_ID" "$RESERVATION_SCOPE"
assign_role "$RESERVATION_PURCHASER_ROLE_ID" "/subscriptions/${MANAGEMENT_SUB_ID}"
# Savings plans role assignments
SAVINGSPLAN_SCOPE="/providers/Microsoft.BillingBenefits"
SAVINGSPLAN_READER_ROLE_ID="d534ad90-4ac5-4815-a178-b2e47397baab"
SAVINGSPLAN_PURCHASER_ROLE_ID="3d24a3a0-c154-4f6f-a5ed-adc8e01ddb74"
assign_role "$SAVINGSPLAN_READER_ROLE_ID" "$SAVINGSPLAN_SCOPE"
assign_role "$SAVINGSPLAN_PURCHASER_ROLE_ID" "$SAVINGSPLAN_SCOPE"
assign_role "$SAVINGSPLAN_PURCHASER_ROLE_ID" "/subscriptions/${MANAGEMENT_SUB_ID}"
# Advisor role assignments
ADVISOR_SCOPE="/providers/Microsoft.Advisor"
ADVISOR_RECOMMENDATIONS_CONTRIBUTOR_ROLE_ID="6b534d80-e337-47c4-864f-140f5c7f593d"
assign_role "$ADVISOR_RECOMMENDATIONS_CONTRIBUTOR_ROLE_ID" "$ADVISOR_SCOPE"
# Summary
echo
echo "===================================================================="
echo " Archera Azure onboarding complete"
echo "===================================================================="
echo " Tenant ID: $TENANT_ID"
echo " Management subscription: $MANAGEMENT_SUB_ID"
echo " Resource group: $RG"
echo " Storage account: $STORAGE"
echo " Custom role: $ROLE_NAME"
echo " Onboarded subscriptions:"
printf " - %s\n" "${SUBSCRIPTIONS[@]}"
echo
echo " Next step: notify your Archera support contact that onboarding is"
echo " complete and share the tenant + subscription IDs above."
echo "===================================================================="
}
confirm() {
local reply
read -rp "$1 [y/Y]: " reply
[[ "$reply" == "y" || "$reply" == "Y" ]] || { echo "Aborted."; exit 1; }
}
# Validates $TENANT_ID and $MANAGEMENT_SUB_ID format, az session tenant, and sub-in-tenant.
validate_inputs() {
local uuid_re='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
local active_tenant sub_tenant
[[ "$TENANT_ID" =~ $uuid_re ]] || { echo "TENANT_ID must be a lowercase UUID, got: '$TENANT_ID'"; exit 1; }
[[ "$MANAGEMENT_SUB_ID" =~ $uuid_re ]] || { echo "MANAGEMENT_SUB_ID must be a lowercase UUID, got: '$MANAGEMENT_SUB_ID'"; exit 1; }
active_tenant=$(az account show --query tenantId -o tsv 2>/dev/null || true)
[[ "$TENANT_ID" == "$active_tenant" ]] || { echo "TENANT_ID '$TENANT_ID' does not match active az session tenant '$active_tenant'. Run 'az login --tenant $TENANT_ID'."; exit 1; }
sub_tenant=$(az account show --subscription "$MANAGEMENT_SUB_ID" --query tenantId -o tsv 2>/dev/null || true)
[[ "$sub_tenant" == "$TENANT_ID" ]] || { echo "Subscription '$MANAGEMENT_SUB_ID' not found in tenant '$TENANT_ID'."; exit 1; }
}
# Idempotent role assignment for $ARCHERA_APP. Args: <role-id> <scope>
assign_role() {
local role_id="$1" scope="$2" output rc=0
output=$(az role assignment create --assignee-object-id "$ARCHERA_APP" --assignee-principal-type ServicePrincipal \
--role "$role_id" --scope "$scope" 2>&1) || rc=$?
if [[ $rc -eq 0 ]]; then
return 0
fi
if grep -q -E "RoleAssignmentExists|already exists" <<< "$output"; then
echo "Role $role_id already assigned at $scope — skipping."
return 0
fi
echo "$output" >&2
return $rc
}
# Creates the Archera custom role at tenant scope; skip-on-exists, never updates.
create_custom_role() {
local description="Archera Custom Role v2"
local actions='[
"*/read",
"Microsoft.Consumption/*",
"Microsoft.CostManagement/*",
"Microsoft.Billing/billingPeriods/read",
"Microsoft.Billing/billingProperty/read",
"Microsoft.Resources/subscriptions/read",
"Microsoft.Resources/subscriptions/resourceGroups/read",
"Microsoft.Advisor/configurations/read",
"Microsoft.Advisor/recommendations/read",
"Microsoft.Management/managementGroups/read"
]'
local tenant_scope="/providers/Microsoft.Management/managementGroups/${TENANT_ID}"
local existing_id role_def_json
echo "Checking for existing custom role..."
existing_id=$(az role definition list --name "$ROLE_NAME" --scope "$tenant_scope" --query "[0].id" -o tsv)
if [[ -n "$existing_id" ]]; then
echo "Custom role '$ROLE_NAME' already exists at $tenant_scope — skipping."
return 0
fi
echo "Creating custom role at tenant scope (may take ~15 seconds)..."
role_def_json=$(cat <<EOF
{
"name": "${ROLE_NAME}",
"roleName": "${ROLE_NAME}",
"description": "${description}",
"type": "CustomRole",
"actions": ${actions},
"notActions": [],
"dataActions": [],
"notDataActions": [],
"assignableScopes": ["${tenant_scope}"]
}
EOF
)
az role definition create --role-definition "$role_def_json"
}
# Discovers eligible subs (FOCUS-supported quota IDs, state=Enabled)
select_subscriptions() {
local quotas="['PayAsYouGo_2014-09-01','EnterpriseAgreement_2014-09-01','CSP_2015-05-01','CSP_MG_2017-12-01','MSDNDevTest_2014-09-01']"
local eligible=() line id name quota status checklist=() selected
echo "Fetching eligible subscriptions..."
while IFS= read -r line; do
eligible+=( "$line" )
done < <(az rest --method get \
--url "https://management.azure.com/subscriptions?api-version=2020-01-01" \
--query "value[?state=='Enabled' && contains(${quotas}, subscriptionPolicies.quotaId)].[subscriptionId, displayName, subscriptionPolicies.quotaId]" \
-o tsv)
[[ ${#eligible[@]} -gt 0 ]] || { echo "No eligible subscriptions found in tenant $TENANT_ID"; exit 1; }
for line in "${eligible[@]}"; do
IFS=$'\t' read -r id name quota <<< "$line"
status="OFF"
[[ "$id" == "$MANAGEMENT_SUB_ID" ]] && status="ON"
checklist+=( "$id" "$name ($quota)" "$status" )
done
selected=$(whiptail --title "Archera Azure Onboarding" --separate-output \
--checklist "Use 'Arrow Keys and Space Bar' to select, 'Tab Key' to OK/Cancel, 'Enter Key' to confirm." \
0 0 "${#eligible[@]}" "${checklist[@]}" \
3>&1 1>&2 2>&3) || { echo "Selection cancelled"; exit 1; }
SUBSCRIPTIONS=()
while IFS= read -r line; do
SUBSCRIPTIONS+=( "$line" )
done <<< "$selected"
[[ ${#SUBSCRIPTIONS[@]} -gt 0 ]] || { echo "No subscriptions selected"; exit 1; }
}
main "$@"