From d85efc43654d4e68bd9fa23e703fde3646097125 Mon Sep 17 00:00:00 2001 From: Brian Gordon Davis <96969185+bgdnext64@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:02:38 -0400 Subject: [PATCH 1/3] feat: handle Authorization_RequestDenied errors with informative message Detect Authorization_RequestDenied errors from Microsoft Graph / Azure AD early in the authorization error parser and return a specific error message explaining these require admin consent or Global Administrator role and cannot be automatically discovered by MPF. Links to Entra ID docs. Closes #45 --- pkg/domain/authorizationErrorParser.go | 12 ++++++++++++ pkg/domain/authorizationErrorParser_test.go | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/pkg/domain/authorizationErrorParser.go b/pkg/domain/authorizationErrorParser.go index 8b2a23e7..91fa946d 100644 --- a/pkg/domain/authorizationErrorParser.go +++ b/pkg/domain/authorizationErrorParser.go @@ -34,6 +34,18 @@ import ( func GetScopePermissionsFromAuthError(authErrMesg string) (map[string][]string, error) { log.Debugf("Attempting to Parse Authorization Error: %s", authErrMesg) + + // Check for Authorization_RequestDenied errors from Microsoft Graph / Azure AD. + // These errors do not contain specific permission details that MPF can parse. + // They typically require Azure AD admin consent or Global Administrator privileges. + if strings.Contains(authErrMesg, "Authorization_RequestDenied") { + log.Warnln("Authorization_RequestDenied error detected. This error originates from Microsoft Graph / Azure AD and cannot be resolved by MPF.") + return nil, fmt.Errorf("Authorization_RequestDenied error detected: %w", + errors.New("this error indicates insufficient Azure AD / Microsoft Graph API privileges. "+ + "These permissions require admin consent or Global Administrator role and cannot be automatically discovered by MPF. "+ + "See https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference for details")) + } + if authErrMesg != "" && !strings.Contains(authErrMesg, "AuthorizationFailed") && !strings.Contains(authErrMesg, "Authorization failed") && !strings.Contains(authErrMesg, "AuthorizationPermissionMismatch") && !strings.Contains(authErrMesg, "LinkedAccessCheckFailed") && !strings.Contains(authErrMesg, "LackOfPermissions") { log.Warnln("Non Authorization Error when creating deployment:", authErrMesg) return nil, errors.New("could not parse deployment error, potentially due to a non-authorization error") diff --git a/pkg/domain/authorizationErrorParser_test.go b/pkg/domain/authorizationErrorParser_test.go index d0418e79..7d3aa1ca 100644 --- a/pkg/domain/authorizationErrorParser_test.go +++ b/pkg/domain/authorizationErrorParser_test.go @@ -85,3 +85,19 @@ func TestMultiAuthorizationSpaceFailedErrors(t *testing.T) { assert.Equal(t, "Microsoft.KeyVault/vaults/write", lastMatch[0]) } + +func TestAuthorizationRequestDeniedError(t *testing.T) { + authRequestDeniedError := `Error: Creating group "Group-name-axtwb" + + with azuread_group.res_ds_group[0], + on rbac.tf line 3, in resource "azuread_group" "res_ds_group": + 3: resource "azuread_group" "res_ds_group" { + +GroupsClient.BaseClient.Post(): unexpected status 403 with OData error: +Authorization_RequestDenied: Insufficient privileges to complete the operation.` + spm, err := GetScopePermissionsFromAuthError(authRequestDeniedError) + assert.NotNil(t, err) + assert.Nil(t, spm) + assert.Contains(t, err.Error(), "Authorization_RequestDenied") + assert.Contains(t, err.Error(), "Azure AD / Microsoft Graph API privileges") +} From ea5a50dc663bc1b7efaaee3337a15185df31094b Mon Sep 17 00:00:00 2001 From: Brian Gordon Davis <96969185+bgdnext64@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:11:57 -0400 Subject: [PATCH 2/3] chore: bump Go 1.26.1 to 1.26.2 to fix stdlib vulnerabilities Fixes govulncheck failures for GO-2026-4947, GO-2026-4946, GO-2026-4870, GO-2026-4866, GO-2026-4865, GO-2026-4864, GO-2026-4869 in crypto/x509, crypto/tls, html/template, internal/syscall/unix, and archive/tar. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 600a9e51..4eb4431a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Azure/mpf -go 1.26.1 +go 1.26.2 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 From 8d4d492ef03d341fbd8923c3298821c755380f9e Mon Sep 17 00:00:00 2001 From: Brian Gordon Davis <96969185+bgdnext64@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:53:19 -0400 Subject: [PATCH 3/3] address PR review: sentinel error, preserve mixed parses, updated docs links, mixed-error unit test, terraform e2e test --- ...erraformAuthorizationRequestDenied_test.go | 86 +++++++++++++++++++ pkg/domain/authorizationErrorParser.go | 29 ++++--- pkg/domain/authorizationErrorParser_test.go | 23 +++++ .../authorization-request-denied/main.tf | 40 +++++++++ 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 e2eTests/e2eTerraformAuthorizationRequestDenied_test.go create mode 100644 samples/terraform/authorization-request-denied/main.tf diff --git a/e2eTests/e2eTerraformAuthorizationRequestDenied_test.go b/e2eTests/e2eTerraformAuthorizationRequestDenied_test.go new file mode 100644 index 00000000..8590d687 --- /dev/null +++ b/e2eTests/e2eTerraformAuthorizationRequestDenied_test.go @@ -0,0 +1,86 @@ +// MIT License +// +// Copyright (c) Microsoft Corporation. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE + +package e2etests + +import ( + "os" + "path" + "runtime" + "strings" + "testing" + + "github.com/Azure/mpf/pkg/infrastructure/authorizationCheckers/terraform" + rgm "github.com/Azure/mpf/pkg/infrastructure/resourceGroupManager" + spram "github.com/Azure/mpf/pkg/infrastructure/spRoleAssignmentManager" + "github.com/Azure/mpf/pkg/usecase" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +// TestTerraformAuthorizationRequestDenied exercises the Authorization_RequestDenied +// path end-to-end. The sample creates an azuread_group, which requires Microsoft +// Graph application permissions (admin consent / Global Administrator) that MPF +// cannot auto-discover. MPF should fail with a clear guidance error instead of +// silently looping or producing a misleading parse error. +func TestTerraformAuthorizationRequestDenied(t *testing.T) { + mpfArgs, err := getTestingMPFArgs() + if err != nil { + t.Skip("required environment variables not set, skipping end to end test") + } + mpfArgs.MPFMode = "terraform" + + if os.Getenv("MPF_TFPATH") == "" { + t.Skip("Terraform Path MPF_TFPATH not set, skipping end to end test") + } + tfpath := os.Getenv("MPF_TFPATH") + + _, filename, _, _ := runtime.Caller(0) + curDir := path.Dir(filename) + log.Infof("curDir: %s", curDir) + wrkDir := path.Join(curDir, "../samples/terraform/authorization-request-denied") + log.Infof("wrkDir: %s", wrkDir) + + // Clean up Terraform artifacts before and after test + cleanTerraformWorkingDir(t, wrkDir) + t.Cleanup(func() { cleanTerraformWorkingDir(t, wrkDir) }) + + ctx := t.Context() + + mpfConfig := getMPFConfig(mpfArgs) + + var rgManager usecase.ResourceGroupManager = rgm.NewResourceGroupManager(mpfArgs.SubscriptionID) + var spRoleAssignmentManager usecase.ServicePrincipalRolemAssignmentManager = spram.NewSPRoleAssignmentManager(mpfArgs.SubscriptionID) + + initialPermissionsToAdd := []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"} + permissionsToAddToResult := []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"} + deploymentAuthorizationCheckerCleaner := terraform.NewTerraformAuthorizationChecker(wrkDir, tfpath, "", true, "") + mpfService := usecase.NewMPFService(ctx, rgManager, spRoleAssignmentManager, deploymentAuthorizationCheckerCleaner, mpfConfig, initialPermissionsToAdd, permissionsToAddToResult, false, true, false) + + _, err = mpfService.GetMinimumPermissionsRequired() + assert.Error(t, err) + if err != nil { + assert.True(t, + strings.Contains(err.Error(), "Authorization_RequestDenied"), + "expected error to mention Authorization_RequestDenied, got: %v", err) + } +} diff --git a/pkg/domain/authorizationErrorParser.go b/pkg/domain/authorizationErrorParser.go index 91fa946d..3a4848c7 100644 --- a/pkg/domain/authorizationErrorParser.go +++ b/pkg/domain/authorizationErrorParser.go @@ -32,21 +32,21 @@ import ( log "github.com/sirupsen/logrus" ) +// ErrAuthorizationRequestDenied is returned when an Authorization_RequestDenied error +// is detected from Microsoft Graph / Azure AD and no other parseable Azure RBAC +// authorization errors can be extracted from the error message. Callers can use +// errors.Is to detect this case programmatically. +var ErrAuthorizationRequestDenied = errors.New("Authorization_RequestDenied: insufficient Azure AD / Microsoft Graph API privileges. " + + "These permissions require admin consent or Global Administrator role and cannot be automatically discovered by MPF. " + + "See https://learn.microsoft.com/en-us/graph/permissions-reference for Microsoft Graph permissions " + + "and https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/guides/service_principal_configuration for Terraform AzureAD provider setup") + func GetScopePermissionsFromAuthError(authErrMesg string) (map[string][]string, error) { log.Debugf("Attempting to Parse Authorization Error: %s", authErrMesg) - // Check for Authorization_RequestDenied errors from Microsoft Graph / Azure AD. - // These errors do not contain specific permission details that MPF can parse. - // They typically require Azure AD admin consent or Global Administrator privileges. - if strings.Contains(authErrMesg, "Authorization_RequestDenied") { - log.Warnln("Authorization_RequestDenied error detected. This error originates from Microsoft Graph / Azure AD and cannot be resolved by MPF.") - return nil, fmt.Errorf("Authorization_RequestDenied error detected: %w", - errors.New("this error indicates insufficient Azure AD / Microsoft Graph API privileges. "+ - "These permissions require admin consent or Global Administrator role and cannot be automatically discovered by MPF. "+ - "See https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference for details")) - } + hasAuthorizationRequestDenied := strings.Contains(authErrMesg, "Authorization_RequestDenied") - if authErrMesg != "" && !strings.Contains(authErrMesg, "AuthorizationFailed") && !strings.Contains(authErrMesg, "Authorization failed") && !strings.Contains(authErrMesg, "AuthorizationPermissionMismatch") && !strings.Contains(authErrMesg, "LinkedAccessCheckFailed") && !strings.Contains(authErrMesg, "LackOfPermissions") { + if authErrMesg != "" && !hasAuthorizationRequestDenied && !strings.Contains(authErrMesg, "AuthorizationFailed") && !strings.Contains(authErrMesg, "Authorization failed") && !strings.Contains(authErrMesg, "AuthorizationPermissionMismatch") && !strings.Contains(authErrMesg, "LinkedAccessCheckFailed") && !strings.Contains(authErrMesg, "LackOfPermissions") { log.Warnln("Non Authorization Error when creating deployment:", authErrMesg) return nil, errors.New("could not parse deployment error, potentially due to a non-authorization error") } @@ -121,6 +121,13 @@ func GetScopePermissionsFromAuthError(authErrMesg string) (map[string][]string, // If map is empty, return error if len(resMap) == 0 { + // If the original error contained Authorization_RequestDenied and nothing + // else parseable was found, surface the dedicated guidance message so the + // user knows this requires admin consent / Global Administrator privileges. + if hasAuthorizationRequestDenied { + log.Warnln("Authorization_RequestDenied error detected. This error originates from Microsoft Graph / Azure AD and cannot be resolved by MPF.") + return nil, ErrAuthorizationRequestDenied + } return nil, fmt.Errorf("could not parse deployment error for scope/permissions: %s", authErrMesg) } diff --git a/pkg/domain/authorizationErrorParser_test.go b/pkg/domain/authorizationErrorParser_test.go index 7d3aa1ca..f427f60e 100644 --- a/pkg/domain/authorizationErrorParser_test.go +++ b/pkg/domain/authorizationErrorParser_test.go @@ -24,6 +24,7 @@ package domain // test parseMultiAuthorizationFailedErrors import ( + "errors" "testing" "github.com/stretchr/testify/assert" @@ -100,4 +101,26 @@ Authorization_RequestDenied: Insufficient privileges to complete the operation.` assert.Nil(t, spm) assert.Contains(t, err.Error(), "Authorization_RequestDenied") assert.Contains(t, err.Error(), "Azure AD / Microsoft Graph API privileges") + assert.True(t, errors.Is(err, ErrAuthorizationRequestDenied), "expected error to wrap ErrAuthorizationRequestDenied") +} + +// TestMixedAuthorizationRequestDeniedAndAuthorizationFailedError ensures that when +// an error message contains both Authorization_RequestDenied and parseable +// AuthorizationFailed entries, MPF still extracts the parseable scope/permissions +// pairs instead of bailing out with the Authorization_RequestDenied guidance. +func TestMixedAuthorizationRequestDeniedAndAuthorizationFailedError(t *testing.T) { + mixedError := `Error: multiple errors occurred during plan/apply: + +GroupsClient.BaseClient.Post(): unexpected status 403 with OData error: +Authorization_RequestDenied: Insufficient privileges to complete the operation. + +{"error":{"code":"AuthorizationFailed","message":"The client 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' with object id 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' does not have authorization to perform action 'Microsoft.Storage/storageAccounts/write' over scope '/subscriptions/SSSSSSSS-SSSS-SSSS-SSSS-SSSSSSSSSSSS/resourcegroups/testdeployrg/providers/Microsoft.Storage/storageAccounts/sa1' or the scope is invalid. If access was recently granted, please refresh your credentials."}}` + + spm, err := GetScopePermissionsFromAuthError(mixedError) + assert.Nil(t, err) + assert.NotNil(t, spm) + assert.GreaterOrEqual(t, len(spm), 1) + + match := spm["/subscriptions/SSSSSSSS-SSSS-SSSS-SSSS-SSSSSSSSSSSS/resourcegroups/testdeployrg/providers/Microsoft.Storage/storageAccounts/sa1"] + assert.Contains(t, match, "Microsoft.Storage/storageAccounts/write") } diff --git a/samples/terraform/authorization-request-denied/main.tf b/samples/terraform/authorization-request-denied/main.tf new file mode 100644 index 00000000..2920e189 --- /dev/null +++ b/samples/terraform/authorization-request-denied/main.tf @@ -0,0 +1,40 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 3.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.0" + } + } +} + +provider "azurerm" { + features {} +} + +provider "azuread" {} + +# This sample is intentionally minimal and is used to exercise the +# Authorization_RequestDenied error path in MPF. Creating an Azure AD group +# requires Microsoft Graph application permissions (e.g. Group.Create) that +# require admin consent or Global Administrator role; these cannot be +# auto-discovered by MPF, so MPF should surface a clear guidance error. +resource "random_string" "rand" { + length = 8 + special = false + numeric = false + upper = false + lower = true +} + +resource "azuread_group" "mpf_test" { + display_name = "mpf-authreqdenied-${random_string.rand.result}" + security_enabled = true +}