Skip to content

feat(terraform): report operationStatuses/read for long running operation resources - #314

Open
Brian Gordon Davis (bgdnext64) wants to merge 4 commits into
mainfrom
feat/62-lro-operationstatuses-permissions
Open

feat(terraform): report operationStatuses/read for long running operation resources#314
Brian Gordon Davis (bgdnext64) wants to merge 4 commits into
mainfrom
feat/62-lro-operationstatuses-permissions

Conversation

@bgdnext64

Copy link
Copy Markdown
Collaborator

Closes #62

What this does

Adds an opt-in behaviour where MPF reports RESOURCE_TYPE/operationStatuses/read alongside a discovered RESOURCE_TYPE/write permission. Resources created through long running operations return 201 Created with an Azure-AsyncOperation header, and the azurerm provider then polls that URL (CreateThenPoll, CreateOrUpdateThenPoll). Without the read permission on the operation status the poll fails even though the create itself succeeded.

Changes

  • pkg/domain/appendOperationStatusesReadPermissions.go derives the candidate permission from a write permission. It skips wildcards, requires a /write suffix, and will not double-append to a permission that already targets operationStatuses.
  • pkg/domain/mpfResultFilterSort.go gains FilterOutPermissions, a case-insensitive removal helper that returns a new map.
  • pkg/usecase/mpfService.go appends candidates each iteration, tracks which permissions MPF added itself, and drops the ones Azure rejects.
  • cmd/terraformCmd.go exposes --autoAddOperationStatusesReadPermission, documented in docs/commandline-flags-and-env-variables.md.
  • samples/terraform/lro-multi-resource/ is a new sample spanning container registry, storage, virtual network, subnet and log analytics.
  • Unit tests for the domain helpers and the service, plus an end to end test.

Things worth a reviewer's attention

The permission is only useful for a small number of providers. I checked with az provider operation show and Microsoft.ContainerRegistry is the only provider I found that exposes the nested RESOURCE_TYPE/operationStatuses action. ContainerInstance, ContainerService, KeyVault, DocumentDB, Compute, Web and Cache do not. OperationalInsights, Network, Storage and App only expose location scoped or specialised variants (locations/operationstatuses, dnsoperationstatuses, locations/*RPOperationStatuses).

So in practice MPF appends a candidate for every write permission, Azure rejects nearly all of them, and they are discarded. The feature works, but the mechanism is speculative by design and worth an explicit decision from a maintainer. If you would prefer a curated allowlist of providers over propose-and-discard, that is a reasonable alternative and I am happy to rework it.

Default differs by layer. The CLI flag defaults to true, matching the unconditional wording in #62. The service level option defaults to false so existing programmatic callers are unaffected.

Related pre-existing bug: #313. Testing this surfaced a separate problem in CreateUpdateCustomRole, which retries 5 times and then returns nil even if every attempt failed. Azure reports invalid actions one at a time, so a handful of rejected candidates exhausts the budget and the role silently stops being updated. This PR avoids triggering it by never resubmitting a rejected candidate, but the underlying trap is untouched and filed separately as #313.

Validation

Unit tests, go build, go vet and gofmt all clean.

End to end against a live subscription using a service principal with no role assignments:

  • TestTerraformMultiResourceWithOperationStatusesReadPermissions — PASS (592s). Result contained Microsoft.ContainerRegistry/registries/operationStatuses/read and no other provider's candidate, with all four non-registry write permissions intact.
  • TestTerraformWithImport — PASS (405s). This is the regression check for the pruning logic; an earlier iteration of this branch incorrectly filtered actions that came from deployment errors, and this test caught it.

The prune fix is measurable on the multi provider sample:

before after
iterations 28+ (killed, no progress) 9
InvalidActionOrNotAction events 127 5
successful role updates 9 10 of 10

The full 21 test end to end suite was green earlier on this branch. I did not re-run all of it after the final commit, since the pruning path is inert when the option is off, but I am happy to if you would like that confirmed.

- append RESOURCE_TYPE/operationStatuses/read for every discovered RESOURCE_TYPE/write
- add --autoAddOperationStatusesReadPermission flag (default true) to the terraform command
- drop actions Azure rejects as InvalidActionOrNotAction from the reported result
- document the new flag and LRO polling behaviour

Closes #62
Add a Terraform sample that provisions an Azure Container Registry, the
only sample resource type that exposes a nested operationStatuses action,
and an end to end test that enables the new option and asserts that
Microsoft.ContainerRegistry/registries/operationStatuses/read is part of
the discovered permissions.

Also limit the invalid action filtering to the permissions MPF appends
itself. Previously every action Azure rejected with
InvalidActionOrNotAction was dropped from the result, which silently
changed the output for deployments whose error messages reference an
invalid action, for example
Microsoft.Insights/components/currentbillingfeatures/delete.
…tting them

MPF appends a RESOURCE_TYPE/operationStatuses/read candidate for every
discovered write permission, but only a few resource providers actually
expose that nested action. Azure rejects the rest with
InvalidActionOrNotAction.

Previously the rejected candidates stayed in the required permissions map,
so every later iteration resubmitted them. Azure reports invalid actions one
at a time and CreateUpdateCustomRole only retries five times, so a handful of
rejected candidates consumed the whole retry budget on every call. The role
then stopped being updated at all while the discovery loop kept iterating.

Rejected candidates are now remembered and pruned as soon as Azure rejects
them, and they are filtered out before new candidates are appended, so each
one costs a single retry exactly once. Only permissions MPF appended itself
are pruned; actions reported by the deployment errors are still returned
unchanged.

Also expands the single resource ACR terraform sample into a multi provider
sample covering container registry, storage, network, subnet and log
analytics, which is what surfaced the resubmission problem. The end to end
test asserts the container registry operationStatuses permission is reported
and that no other provider's candidate leaks into the result.

Measured against a live subscription, the multi resource run went from 28+
iterations and 127 invalid action events to 9 iterations and 5 events, with
every custom role update succeeding.

Refs #62
@bgdnext64
Brian Gordon Davis (bgdnext64) requested a review from a team as a code owner July 27, 2026 20:51
Copilot AI review requested due to automatic review settings July 27, 2026 20:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an opt-in Terraform behavior to automatically include RESOURCE_TYPE/operationStatuses/read alongside discovered RESOURCE_TYPE/write permissions to support azurerm resources created via long-running operations (LRO polling), while pruning candidates that Azure rejects as invalid actions.

Changes:

  • Add domain helpers to derive and append .../operationStatuses/read permissions from .../write.
  • Add a case-insensitive permission removal helper and update the MPF service to track/prune rejected auto-added permissions across iterations.
  • Expose a Terraform CLI flag, add a multi-provider Terraform sample, and introduce unit + e2e coverage for the new behavior.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pkg/usecase/mpfService.go Adds opt-in LRO permission auto-append, tracks auto-added vs rejected permissions, and prunes rejected candidates during iterations.
pkg/usecase/mpfService_test.go Unit tests for invalid-action recording/pruning logic and the new service option.
pkg/domain/appendOperationStatusesReadPermissions.go New helper to derive and append operationStatuses/read permissions from write actions.
pkg/domain/appendOperationStatusesReadPermissions_test.go Unit tests for deriving/appending LRO polling permissions.
pkg/domain/mpfResultFilterSort.go Adds case-insensitive FilterOutPermissions helper used for pruning rejected actions.
pkg/domain/mpfResultFilterSort_test.go Unit tests for FilterOutPermissions, including non-mutation expectations.
cmd/terraformCmd.go Adds --autoAddOperationStatusesReadPermission flag and wires it into MPF service options.
cmd/terraformCmd_test.go Verifies the new Terraform flag is registered, defaults to true, and is settable.
docs/commandline-flags-and-env-variables.md Documents the new Terraform flag and explains the LRO polling permission behavior.
e2eTests/e2eTerraformOperationStatuses_test.go Adds an e2e test validating inclusion of valid operationStatuses/read and pruning of rejected candidates.
samples/terraform/lro-multi-resource/main.tf New Terraform sample deploying multiple resources to exercise append-and-prune behavior.
samples/terraform/lro-multi-resource/variables.tf Sample variable definition for location.
samples/terraform/lro-multi-resource/dev.vars.tfvars Sample dev variables file.
samples/terraform/lro-multi-resource/output.tf Sample outputs for deployed resources.

Comment thread pkg/usecase/mpfService.go
Comment on lines +252 to +264
// auto add the LRO polling permission for each discovered write permission
if s.autoAddOperationStatusesReadForWrite {
scpMp = domain.AppendOperationStatusesReadPermissions(scpMp)
// candidates Azure already rejected must not be added back
scpMp = domain.FilterOutPermissions(scpMp, s.rejectedAutoAddedList)
for _, permissions := range scpMp {
for _, permission := range permissions {
if strings.HasSuffix(strings.ToLower(permission), strings.ToLower(domain.OperationStatusesReadSuffix)) {
s.autoAddedPermissions[strings.ToLower(permission)] = true
}
}
}
}
Comment on lines +34 to +36
if len(permissionsToRemove) == 0 {
return scpPerms
}
Comment on lines +73 to +77
// AppendOperationStatusesReadPermissions appends the LRO polling read permission for every
// resource type write permission found in the supplied scope/permission map.
//
// Permissions that Azure does not recognise are removed later on, when the custom role update
// reports them as InvalidActionOrNotAction.
@@ -0,0 +1,4 @@
variable "location" {
description = "The supported azure location where the resource exists"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (2)

pkg/usecase/mpfService.go:261

  • When tracking auto-added operationStatuses permissions, the key is lowercased but not trimmed. Trimming here keeps keying consistent with recordInvalidActions/FilterOutPermissions and avoids missed matches if any permission strings contain whitespace.
					if strings.HasSuffix(strings.ToLower(permission), strings.ToLower(domain.OperationStatusesReadSuffix)) {
						s.autoAddedPermissions[strings.ToLower(permission)] = true
					}

samples/terraform/lro-multi-resource/variables.tf:2

  • The variable description reads like the resource already exists and uses lowercase “azure”. Aligning with other samples’ wording makes the sample clearer (this configuration deploys resources).
  description = "The supported azure location where the resource exists"

Comment on lines +47 to +51
if !strings.HasSuffix(permission, "/write") {
return "", false
}

resourceType := strings.TrimSuffix(permission, "/write")
Comment thread pkg/usecase/mpfService.go
Comment on lines +111 to +117
key := strings.ToLower(invalidAction)
if !s.autoAddedPermissions[key] || s.rejectedAutoAdded[key] {
continue
}
s.rejectedAutoAdded[key] = true
s.rejectedAutoAddedList = append(s.rejectedAutoAddedList, invalidAction)
newlyRejected = append(newlyRejected, invalidAction)
Comment on lines +1 to +5
terraform {}

provider "azurerm" {
features {}
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

pkg/usecase/mpfService.go:263

  • The autoAddedPermissions tracking marks any permission ending in /operationStatuses/read as "auto-added". However GetScopePermissionsFromAuthError can already return an /operationStatuses/read action directly from deployment errors (e.g. LinkedAuthorizationFailed), so this can cause deployment-reported permissions to be treated as auto-added and later dropped by recordInvalidActions, contradicting the comment that deployment-reported actions are left untouched. Track only the permissions that were actually appended by this feature (i.e., candidates not already present before appending).
		// auto add the LRO polling permission for each discovered write permission
		if s.autoAddOperationStatusesReadForWrite {
			scpMp = domain.AppendOperationStatusesReadPermissions(scpMp)
			// candidates Azure already rejected must not be added back
			scpMp = domain.FilterOutPermissions(scpMp, s.rejectedAutoAddedList)
			for _, permissions := range scpMp {
				for _, permission := range permissions {
					if strings.HasSuffix(strings.ToLower(permission), strings.ToLower(domain.OperationStatusesReadSuffix)) {
						s.autoAddedPermissions[strings.ToLower(permission)] = true
					}
				}
			}

samples/terraform/lro-multi-resource/variables.tf:2

  • The variable description says the location is where "the resource exists", but this sample creates resources. Updating the wording avoids confusion for users of the sample.
  description = "The supported azure location where the resource exists"

pkg/domain/appendOperationStatusesReadPermissions_test.go:2

  • This new test file is missing the standard MIT license header that appears at the top of other Go files in this repository (including other pkg/domain/*_test.go files). Add the header for consistency and license compliance.
package domain

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

For Terraform azurerm provider resources which use LRO polling add RESOURCE_TYPE/operationStatuses/read permissions

2 participants