Repository navigation
fix(iac/azure): custom role grants Microsoft.Capacity/reservationOrders/purchase/action #744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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')]", | ||
|
Comment on lines
+88
to
95
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In Azure, Microsoft.Authorization/roleAssignments names must be a globally unique identifier (GUID) [1][2][3]. Crucially, these names must be unique within the Microsoft Entra tenant, regardless of the scope at which the role assignment is applied [1][2][4]. Regarding your scenario: 1. Conflict Potential: If you attempt to use the same GUID (name) for two different role assignments, Azure will interpret this as an update to the existing assignment rather than the creation of a new, separate one [5]. If the properties of the new assignment (e.g., roleDefinitionId, principalId, or scope) differ from the existing one, the deployment will likely fail or cause an immutable update error, depending on the specifics of the change [1][5][6]. 2. Immutable Update Failure: Because a role assignment name is the identifier used to track the resource, changing its core properties while keeping the same name essentially asks Azure to modify an existing resource [5]. Since role assignments are tracked by name across the tenant, you cannot have multiple distinct assignments share the same name [1][2]. To avoid conflicts and ensure idempotency in your ARM templates or Bicep files, it is best practice to generate a deterministic GUID based on the unique combination of the scope, principal ID, and role definition ID (for example, by using the guid function in Bicep or ARM) [4][5][7]. This ensures that for a specific set of parameters, the same assignment name is always generated, preventing accidental duplicates while ensuring that unrelated assignments do not collide [4][7]. Citations:
Make the provider-scope role assignment
Suggested patch- "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRoleCapacity')]",
+ "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRoleCapacity', subscription().subscriptionId)]",🤖 Prompt for AI Agents |
||
| "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" | ||
| } | ||
| } | ||
| ], | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: LeanerCloud/CUDly
Length of output: 814
Add the missing RBAC actions to the custom role permissions
The custom role action list in
arm/CUDly-CrossSubscription/template.jsonomits these required actions, so exchange and some savings-plan flows can still fail with 403:Microsoft.Capacity/calculateExchange/actionMicrosoft.Capacity/exchange/actionMicrosoft.BillingBenefits/savingsPlanOrders/writeMicrosoft.BillingBenefits/validate/actionSuggested patch
"actions": [ "Microsoft.Capacity/register/action", "Microsoft.Capacity/calculatePrice/action", + "Microsoft.Capacity/calculateExchange/action", "Microsoft.Capacity/catalogs/read", "Microsoft.Capacity/reservationOrders/read", "Microsoft.Capacity/reservationOrders/write", "Microsoft.Capacity/reservationOrders/purchase/action", + "Microsoft.Capacity/exchange/action", "Microsoft.Capacity/reservationOrders/reservations/read", "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/write", "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", - "Microsoft.BillingBenefits/savingsPlanOrders/action" + "Microsoft.BillingBenefits/savingsPlanOrders/action", + "Microsoft.BillingBenefits/validate/action" ],🤖 Prompt for AI Agents