diff --git a/arm/CUDly-CrossSubscription/template.json b/arm/CUDly-CrossSubscription/template.json index 7607366c1..44f2f0d91 100644 --- a/arm/CUDly-CrossSubscription/template.json +++ b/arm/CUDly-CrossSubscription/template.json @@ -3,7 +3,7 @@ "contentVersion": "1.0.0.0", "metadata": { - "description": "CUDly Cross-Subscription Role Assignments — deploy this in every target Azure subscription that CUDly should manage. It grants the CUDly service principal the permissions needed to query reservation recommendations and purchase Azure Reservations." + "description": "CUDly Cross-Subscription Role Assignments — deploy this in every target Azure subscription that CUDly should manage. It grants the CUDly service principal the permissions needed to query reservation recommendations and purchase Azure Reservations and Savings Plans." }, "parameters": { @@ -27,10 +27,58 @@ "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" - } + }, + "customRoleName": "[guid(subscription().subscriptionId, 'cudly-reservation-purchaser')]", + "customRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', guid(subscription().subscriptionId, 'cudly-reservation-purchaser'))]" }, "resources": [ + { + "type": "Microsoft.Authorization/roleDefinitions", + "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.", + "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/catalogs/read", + "Microsoft.BillingBenefits/savingsPlanOrderAliases/write", + "Microsoft.BillingBenefits/savingsPlanOrders/read", + "Microsoft.BillingBenefits/savingsPlanOrders/savingsPlans/read", + "Microsoft.BillingBenefits/savingsPlanOrders/action" + ], + "notActions": [] + } + ], + "assignableScopes": [ + "[subscription().id]" + ] + } + }, + + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(parameters('servicePrincipalObjectId'), 'cudlyCustomRole', subscription().subscriptionId)]", + "dependsOn": [ + "[variables('customRoleDefinitionId')]" + ], + "properties": { + "roleDefinitionId": "[variables('customRoleDefinitionId')]", + "principalId": "[parameters('servicePrincipalObjectId')]", + "principalType": "ServicePrincipal", + "description": "CUDly — purchase reservations and savings plans via custom role that enumerates every required action explicitly" + } + }, + { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", @@ -42,6 +90,7 @@ "description": "CUDly — enumerate subscription resources and locations" } }, + { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", @@ -53,6 +102,7 @@ "description": "CUDly — read cost and reservation utilisation data" } }, + { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", @@ -61,9 +111,10 @@ "roleDefinitionId": "[variables('roles').reservationPurchaser]", "principalId": "[parameters('servicePrincipalObjectId')]", "principalType": "ServicePrincipal", - "description": "CUDly — purchase Azure Reservations in this subscription" + "description": "CUDly — reservation catalog reads and recommendations via built-in Reservation Purchaser role (kept in addition to the custom role)" } }, + { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", @@ -92,6 +143,13 @@ "metadata": { "description": "Azure AD tenant ID to provide when registering this account in CUDly (azure_tenant_id field)." } + }, + "customRoleDefinitionId": { + "type": "string", + "value": "[variables('customRoleDefinitionId')]", + "metadata": { + "description": "Resource ID of the CUDly custom role definition created in this subscription." + } } } } diff --git a/known-issues.md b/known-issues.md index 8d2575807..df36aac8d 100644 --- a/known-issues.md +++ b/known-issues.md @@ -1,8 +1,34 @@ # Known Issues -The seven limitations previously listed here have been resolved or -scoped-down with explicit follow-ups. This file tracks what's still -outstanding so future work has a clear starting point. +This file tracks outstanding limitations and things that require operator +action. Resolved items are moved to the Resolved section at the bottom. + +## Outstanding + +### Azure ARM template re-deployment required for purchase support (issue #731) + +The built-in "Reservation Purchaser" role (f7b75c60-3036-4b75-91c3-6b41c27c1689) +does not include `Microsoft.Capacity/calculateprice/action`, +`Microsoft.Capacity/reservationorders/write`, or +`Microsoft.BillingBenefits/savingsPlanOrderAliases/write`. Without these, +the live purchase API returns 403. + +`arm/CUDly-CrossSubscription/template.json` has been updated (fix/731-arm-roles) +to add a custom role "CUDly Reservation and Savings Plan Purchaser" that enumerates +all required actions explicitly. Existing tenants who applied the ARM template before +this fix MUST re-deploy it: + +```bash +az deployment sub create \ + --location eastus \ + --template-file arm/CUDly-CrossSubscription/template.json \ + --parameters servicePrincipalObjectId= \ + --name CUDly-CrossSubscription \ + --no-prompt +``` + +Until re-deployed, `PurchaseCommitment` and `ValidateOffering` for savings plans +will continue to return 403. ## Resolved @@ -184,10 +210,12 @@ outstanding so future work has a clear starting point. - **GCP account `serene-bazaar-666` deploy SA missing `compute.regions.list`**: Visible in production Lambda logs (`2026-04-21T16:28:22Z` and onward): - [ERROR] GCP account GCP serene-bazaar-666 (serene-bazaar-666): - get recommendations: failed to get regions: failed to list regions: - googleapi: Error 403: Required 'compute.regions.list' permission - for 'projects/serene-bazaar-666' + ```text + [ERROR] GCP account GCP serene-bazaar-666 (serene-bazaar-666): + get recommendations: failed to get regions: failed to list regions: + googleapi: Error 403: Required 'compute.regions.list' permission + for 'projects/serene-bazaar-666' + ``` The deploy service account that CUDly impersonates for that project doesn't have `roles/compute.viewer` (or a custom role that includes