From e47429e7439e178f92d97af91bc7f120a91203ba Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 12:34:36 +0200 Subject: [PATCH 1/2] fix(iac/azure): custom role grants Microsoft.Capacity/reservationOrders/purchase/action The built-in Reservation Purchaser role lacks Microsoft.Capacity/reservationOrders/purchase/action, which the two-step calculatePrice -> purchase flow (PR #680) requires. Replace it with a custom role that explicitly enumerates the runtime actions used by the reservation + savings-plan purchase paths, assignable at subscription scope AND /providers/Microsoft.Capacity. Fixes 403 AuthorizationFailed on live reservation purchases for newly onboarded Azure subscriptions. Closes #731 (if open). --- arm/CUDly-CrossSubscription/template.json | 60 +++++++++---------- iac/federation/azure-target/terraform/main.tf | 44 ++++++++++++-- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/arm/CUDly-CrossSubscription/template.json b/arm/CUDly-CrossSubscription/template.json index 44f2f0d91..8b7f17e21 100644 --- a/arm/CUDly-CrossSubscription/template.json +++ b/arm/CUDly-CrossSubscription/template.json @@ -25,8 +25,7 @@ "variables": { "roles": { "reader": "/providers/Microsoft.Authorization/roleDefinitions/acdd72a7-3385-48ef-bd42-f606fba81ae7", - "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3", - "reservationPurchaser": "/providers/Microsoft.Authorization/roleDefinitions/f7b75c60-3036-4b75-91c3-6b41c27c1689" + "costManagementReader": "/providers/Microsoft.Authorization/roleDefinitions/72fafb9e-0641-4937-9268-a91bfd8191a3" }, "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" @@ -38,28 +37,32 @@ "apiVersion": "2022-04-01", "name": "[variables('customRoleName')]", "properties": { - "roleName": "CUDly Reservation and Savings Plan Purchaser", - "description": "Custom role granting CUDly exactly the Microsoft.Capacity and Microsoft.BillingBenefits actions it calls at runtime. Complements the built-in Reservation Purchaser assignment (which covers catalog reads and recommendations) by adding the purchase and calculatePrice actions that the built-in role omits.", + "roleName": "CUDly Reservation Purchaser (custom)", + "description": "Custom role granting CUDly exactly the Microsoft.Capacity and Microsoft.BillingBenefits actions required by the calculatePrice -> purchase flow. Replaces the built-in Reservation Purchaser, which lacks reservationOrders/purchase/action.", "type": "CustomRole", "permissions": [ { "actions": [ - "Microsoft.Capacity/calculateprice/action", - "Microsoft.Capacity/reservationorders/write", - "Microsoft.Capacity/reservationorders/read", - "Microsoft.Capacity/reservationorders/reservations/read", "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/purchase/action", + "Microsoft.Capacity/reservationOrders/reservations/read", "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", "Microsoft.BillingBenefits/savingsPlanOrders/read", "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", "Microsoft.BillingBenefits/savingsPlanOrders/action" ], - "notActions": [] + "notActions": [], + "dataActions": [], + "notDataActions": [] } ], "assignableScopes": [ - "[subscription().id]" + "[concat('/subscriptions/', subscription().subscriptionId)]", + "/providers/Microsoft.Capacity" ] } }, @@ -75,56 +78,47 @@ "roleDefinitionId": "[variables('customRoleDefinitionId')]", "principalId": "[parameters('servicePrincipalObjectId')]", "principalType": "ServicePrincipal", - "description": "CUDly — purchase reservations and savings plans via custom role that enumerates every required action explicitly" + "description": "CUDly — subscription-scope assignment of custom role; grants calculatePrice + purchase/action required by the two-step reservation purchase flow" } }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", - "properties": { - "roleDefinitionId": "[variables('roles').reader]", - "principalId": "[parameters('servicePrincipalObjectId')]", - "principalType": "ServicePrincipal", - "description": "CUDly — enumerate subscription resources and locations" - } - }, - - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", + "scope": "/providers/Microsoft.Capacity", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRoleCapacity')]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], "properties": { - "roleDefinitionId": "[variables('roles').costManagementReader]", + "roleDefinitionId": "[variables('customRoleDefinitionId')]", "principalId": "[parameters('servicePrincipalObjectId')]", "principalType": "ServicePrincipal", - "description": "CUDly — read cost and reservation utilisation data" + "description": "CUDly — tenant-capacity-scope assignment of custom role; required for reservationOrders/purchase/action at /providers/Microsoft.Capacity scope" } }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "name": "[guid(parameters('servicePrincipalObjectId'), 'reservationPurchaser', subscription().subscriptionId)]", + "name": "[guid(parameters('servicePrincipalObjectId'), 'reader', subscription().subscriptionId)]", "properties": { - "roleDefinitionId": "[variables('roles').reservationPurchaser]", + "roleDefinitionId": "[variables('roles').reader]", "principalId": "[parameters('servicePrincipalObjectId')]", "principalType": "ServicePrincipal", - "description": "CUDly — reservation catalog reads and recommendations via built-in Reservation Purchaser role (kept in addition to the custom role)" + "description": "CUDly — enumerate subscription resources and locations" } }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", - "scope": "/providers/Microsoft.Capacity", - "name": "[guid(parameters('servicePrincipalObjectId'), 'reservationPurchaserCapacity')]", + "name": "[guid(parameters('servicePrincipalObjectId'), 'costManagementReader', subscription().subscriptionId)]", "properties": { - "roleDefinitionId": "[variables('roles').reservationPurchaser]", + "roleDefinitionId": "[variables('roles').costManagementReader]", "principalId": "[parameters('servicePrincipalObjectId')]", "principalType": "ServicePrincipal", - "description": "CUDly — read + purchase Azure Reservations at tenant capacity scope. Supersets Reservation Reader (582fc458-8989-419f-a480-75249a578f9d), which is not provisioned in every tenant and would otherwise fail with RoleDefinitionDoesNotExist on those tenants." + "description": "CUDly — read cost and reservation utilisation data" } } ], diff --git a/iac/federation/azure-target/terraform/main.tf b/iac/federation/azure-target/terraform/main.tf index 8c4e22c9f..62e612c73 100644 --- a/iac/federation/azure-target/terraform/main.tf +++ b/iac/federation/azure-target/terraform/main.tf @@ -58,9 +58,45 @@ resource "azuread_application_federated_identity_credential" "cudly" { subject = var.cudly_federated_subject } -# Reservation Purchaser is the built-in Azure role for purchasing and managing reservations. +# Custom role: grants the exact Microsoft.Capacity and Microsoft.BillingBenefits actions +# used by the calculatePrice -> purchase flow (introduced in PR #680). The built-in +# Reservation Purchaser role lacks reservationOrders/purchase/action, which causes 403 on +# production reservation purchases. +resource "azurerm_role_definition" "cudly_reservation_purchaser" { + name = "CUDly Reservation Purchaser (custom) - ${local.subscription_id}" + scope = data.azurerm_subscription.current.id + description = "Custom role granting CUDly exactly the Microsoft.Capacity and Microsoft.BillingBenefits actions required by the calculatePrice -> purchase flow. Replaces the built-in Reservation Purchaser, which lacks reservationOrders/purchase/action." + + permissions { + actions = [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/purchase/action", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action", + ] + not_actions = [] + } + + assignable_scopes = [ + data.azurerm_subscription.current.id, + "/providers/Microsoft.Capacity", + ] +} + +# Assign the custom role at subscription scope. +# depends_on ensures the role definition is fully propagated before the assignment +# is created (Azure RBAC propagation can take up to 10 minutes). resource "azurerm_role_assignment" "cudly_reservations" { - scope = "/subscriptions/${local.subscription_id}" - role_definition_name = "Reservation Purchaser" - principal_id = azuread_service_principal.cudly.object_id + scope = "/subscriptions/${local.subscription_id}" + role_definition_id = azurerm_role_definition.cudly_reservation_purchaser.role_definition_resource_id + principal_id = azuread_service_principal.cudly.object_id + + depends_on = [azurerm_role_definition.cudly_reservation_purchaser] } From bd861f8e2c76a338e2ed0558c431b9738d34078c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 27 May 2026 13:57:57 +0200 Subject: [PATCH 2/2] fix(iac/azure): mirror reservation-purchaser custom role on host SP + add Reader The host container-app identity was still on the built-in Reservation Purchaser role (which omits reservationOrders/purchase/action, the same bug this PR fixes customer-side). Adds the same custom role plus a subscription-scope Reader, factoring the role definition into a shared module so customer + host stay in lockstep. - New module: terraform/modules/iam/azure/cudly-reservation-role (main.tf / variables.tf / outputs.tf) - single source of truth for the custom role's action list - iac/federation/azure-target/terraform/main.tf: switch from inlined azurerm_role_definition to the shared module (no behavioural change) - terraform/modules/compute/azure/container-apps/main.tf: replace built-in Reservation Purchaser assignment with custom-role module + add subscription-scope Reader for host-account resource enumeration Refs #731. --- iac/federation/azure-target/terraform/main.tf | 35 +++---------- .../compute/azure/container-apps/main.tf | 31 +++++++++--- .../.terraform.lock.hcl | 22 ++++++++ .../iam/azure/cudly-reservation-role/main.tf | 50 +++++++++++++++++++ .../azure/cudly-reservation-role/outputs.tf | 4 ++ .../azure/cudly-reservation-role/variables.tf | 9 ++++ 6 files changed, 118 insertions(+), 33 deletions(-) create mode 100644 terraform/modules/iam/azure/cudly-reservation-role/.terraform.lock.hcl create mode 100644 terraform/modules/iam/azure/cudly-reservation-role/main.tf create mode 100644 terraform/modules/iam/azure/cudly-reservation-role/outputs.tf create mode 100644 terraform/modules/iam/azure/cudly-reservation-role/variables.tf diff --git a/iac/federation/azure-target/terraform/main.tf b/iac/federation/azure-target/terraform/main.tf index 62e612c73..8d8c0b7e3 100644 --- a/iac/federation/azure-target/terraform/main.tf +++ b/iac/federation/azure-target/terraform/main.tf @@ -62,32 +62,13 @@ resource "azuread_application_federated_identity_credential" "cudly" { # used by the calculatePrice -> purchase flow (introduced in PR #680). The built-in # Reservation Purchaser role lacks reservationOrders/purchase/action, which causes 403 on # production reservation purchases. -resource "azurerm_role_definition" "cudly_reservation_purchaser" { - name = "CUDly Reservation Purchaser (custom) - ${local.subscription_id}" +# +# The role definition is factored into a shared module so the customer-side (here) and +# host-side (terraform/modules/compute/azure/container-apps) definitions stay in lockstep. +module "cudly_reservation_role" { + source = "../../../../terraform/modules/iam/azure/cudly-reservation-role" scope = data.azurerm_subscription.current.id - description = "Custom role granting CUDly exactly the Microsoft.Capacity and Microsoft.BillingBenefits actions required by the calculatePrice -> purchase flow. Replaces the built-in Reservation Purchaser, which lacks reservationOrders/purchase/action." - - permissions { - actions = [ - "Microsoft.Capacity/register/action", - "Microsoft.Capacity/calculatePrice/action", - "Microsoft.Capacity/catalogs/read", - "Microsoft.Capacity/reservationOrders/read", - "Microsoft.Capacity/reservationOrders/write", - "Microsoft.Capacity/reservationOrders/purchase/action", - "Microsoft.Capacity/reservationOrders/reservations/read", - "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", - "Microsoft.BillingBenefits/savingsPlanOrders/read", - "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", - "Microsoft.BillingBenefits/savingsPlanOrders/action", - ] - not_actions = [] - } - - assignable_scopes = [ - data.azurerm_subscription.current.id, - "/providers/Microsoft.Capacity", - ] + name_suffix = local.subscription_id } # Assign the custom role at subscription scope. @@ -95,8 +76,8 @@ resource "azurerm_role_definition" "cudly_reservation_purchaser" { # is created (Azure RBAC propagation can take up to 10 minutes). resource "azurerm_role_assignment" "cudly_reservations" { scope = "/subscriptions/${local.subscription_id}" - role_definition_id = azurerm_role_definition.cudly_reservation_purchaser.role_definition_resource_id + role_definition_id = module.cudly_reservation_role.role_definition_resource_id principal_id = azuread_service_principal.cudly.object_id - depends_on = [azurerm_role_definition.cudly_reservation_purchaser] + depends_on = [module.cudly_reservation_role] } diff --git a/terraform/modules/compute/azure/container-apps/main.tf b/terraform/modules/compute/azure/container-apps/main.tf index 4c367d5f9..57237fcbf 100644 --- a/terraform/modules/compute/azure/container-apps/main.tf +++ b/terraform/modules/compute/azure/container-apps/main.tf @@ -249,16 +249,35 @@ resource "azurerm_role_assignment" "cost_management_reader" { principal_id = azurerm_user_assigned_identity.container_app.principal_id } -# Reservation Purchaser: allows writing reservationOrders so CUDly can -# purchase/exchange Azure reservations on behalf of users. -# Note: "Reservations Reader" does not exist as a built-in Azure role. -# Read access to reservations is covered by the Reservation Purchaser role itself. -resource "azurerm_role_assignment" "reservations_purchaser" { +# Reader: allows the host container-app identity to enumerate VMs, Redis, +# Cosmos, Search, SQL, and compute SKUs in the host subscription when the host +# account is ingested as a "Self" account. Without this, every per-service SDK +# list call against the host subscription returns 403. +resource "azurerm_role_assignment" "subscription_reader" { scope = data.azurerm_subscription.current.id - role_definition_name = "Reservation Purchaser" + role_definition_name = "Reader" principal_id = azurerm_user_assigned_identity.container_app.principal_id } +# Custom reservation-purchaser role: same role as customer-side but scoped to +# the host subscription. The built-in Reservation Purchaser lacks +# reservationOrders/purchase/action (same gap fixed customer-side by PR #744). +# The role definition is factored into a shared module so both sides stay in +# lockstep with the ARM template. +module "cudly_reservation_role" { + source = "../../../iam/azure/cudly-reservation-role" + scope = data.azurerm_subscription.current.id + name_suffix = data.azurerm_subscription.current.subscription_id +} + +resource "azurerm_role_assignment" "reservations_purchaser" { + scope = data.azurerm_subscription.current.id + role_definition_id = module.cudly_reservation_role.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.container_app.principal_id + + depends_on = [module.cudly_reservation_role] +} + # Key Vault Crypto User: allows the container app's managed identity # to call Sign + GetKey on the OIDC signing key. The key never leaves # the vault — the app only receives signatures computed inside Azure. diff --git a/terraform/modules/iam/azure/cudly-reservation-role/.terraform.lock.hcl b/terraform/modules/iam/azure/cudly-reservation-role/.terraform.lock.hcl new file mode 100644 index 000000000..2de73cb27 --- /dev/null +++ b/terraform/modules/iam/azure/cudly-reservation-role/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.74.0" + constraints = ">= 3.0.0" + hashes = [ + "h1:PBiXTNz3vQjxlmEEO4ZwaEPms8ztbtf+NIIUuyDgtwI=", + "zh:004decccb53c332710894b890f3a5fd724aeeb440c32a8d337d6a2fe9f4427cc", + "zh:10ee232dfa76c987cbd226f81f825bbbe36786a8097fcb811ff655f2b471959c", + "zh:43ffac27efbbcc741ec6e0e96e0def151ddb04d7ce7fd0b67032dc4cdaa20e90", + "zh:459438a78b6cb43ba09e2c11832c6234848a08ab264dc2efe809db8b073057d6", + "zh:4f156cb69b8ed3d43b52fd90106d06609736019bc79faf6c1695aa942bbdade4", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:921285f6e9beb02a7482bb8036e6b032255bc0c4aa61a11def63870c1ee10672", + "zh:994cf51bdc8aefa7e8a93474a322c9231df3512e85bc603c9295343ebc31228c", + "zh:9c6136ed2ae6cbbbfc57e74cdd7b83af9faea5ca3c7e3fa82877aca0b215fd27", + "zh:a0349f105af1882bf9d795a6fdac2fbe71f2e588ca6f4587b87de7b5423a0be8", + "zh:c7a91501928e23d5d8f088909aaa633a13d5f032fbc2043c6618305beb090058", + "zh:f11cb049e16a459f799c4f394ac20f8e9b3d0ef210cf56cb0850e0beffeaf4e1", + ] +} diff --git a/terraform/modules/iam/azure/cudly-reservation-role/main.tf b/terraform/modules/iam/azure/cudly-reservation-role/main.tf new file mode 100644 index 000000000..bdd840881 --- /dev/null +++ b/terraform/modules/iam/azure/cudly-reservation-role/main.tf @@ -0,0 +1,50 @@ +terraform { + required_version = ">= 1.5" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.0" + } + } +} + +# Custom role: grants the exact Microsoft.Capacity and Microsoft.BillingBenefits +# actions used by the calculatePrice -> purchase flow. The built-in Reservation +# Purchaser role lacks reservationOrders/purchase/action, which causes 403 on +# production reservation purchases. +# +# Used by both: +# - customer-side IaC (iac/federation/azure-target/terraform) for the +# customer SP that CUDly authenticates with via workload identity federation +# - host-side IaC (terraform/modules/compute/azure/container-apps) for the +# container-app user-assigned identity running the CUDly process itself +# +# Keep the actions list here in sync with arm/CUDly-CrossSubscription/template.json. +resource "azurerm_role_definition" "cudly_reservation_purchaser" { + name = "CUDly Reservation Purchaser (custom) - ${var.name_suffix}" + scope = var.scope + description = "Custom role granting CUDly exactly the Microsoft.Capacity and Microsoft.BillingBenefits actions required by the calculatePrice -> purchase flow. Replaces the built-in Reservation Purchaser, which lacks reservationOrders/purchase/action." + + permissions { + actions = [ + "Microsoft.Capacity/register/action", + "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/catalogs/read", + "Microsoft.Capacity/reservationOrders/read", + "Microsoft.Capacity/reservationOrders/write", + "Microsoft.Capacity/reservationOrders/purchase/action", + "Microsoft.Capacity/reservationOrders/reservations/read", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action", + ] + not_actions = [] + } + + assignable_scopes = [ + var.scope, + "/providers/Microsoft.Capacity", + ] +} diff --git a/terraform/modules/iam/azure/cudly-reservation-role/outputs.tf b/terraform/modules/iam/azure/cudly-reservation-role/outputs.tf new file mode 100644 index 000000000..538837c0f --- /dev/null +++ b/terraform/modules/iam/azure/cudly-reservation-role/outputs.tf @@ -0,0 +1,4 @@ +output "role_definition_resource_id" { + description = "Full ARM resource ID of the custom role definition. Use as role_definition_id in azurerm_role_assignment blocks." + value = azurerm_role_definition.cudly_reservation_purchaser.role_definition_resource_id +} diff --git a/terraform/modules/iam/azure/cudly-reservation-role/variables.tf b/terraform/modules/iam/azure/cudly-reservation-role/variables.tf new file mode 100644 index 000000000..23976b6d5 --- /dev/null +++ b/terraform/modules/iam/azure/cudly-reservation-role/variables.tf @@ -0,0 +1,9 @@ +variable "scope" { + description = "Subscription resource ID used as the role definition scope and the base of assignable_scopes (e.g. data.azurerm_subscription.current.id)." + type = string +} + +variable "name_suffix" { + description = "Suffix appended to the role display name to keep customer and host role definitions distinct within the same Azure AD tenant (e.g. the subscription ID)." + type = string +}