Skip to content

Commit eb492d3

Browse files
feat: handle Authorization_RequestDenied errors with informative message (#254)
Co-authored-by: Mani Bindra <maniSbindra@users.noreply.github.com>
1 parent 35ef6a2 commit eb492d3

4 files changed

Lines changed: 185 additions & 1 deletion

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// MIT License
2+
//
3+
// Copyright (c) Microsoft Corporation.
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE
22+
23+
package e2etests
24+
25+
import (
26+
"os"
27+
"path"
28+
"runtime"
29+
"strings"
30+
"testing"
31+
32+
"github.com/Azure/mpf/pkg/infrastructure/authorizationCheckers/terraform"
33+
rgm "github.com/Azure/mpf/pkg/infrastructure/resourceGroupManager"
34+
spram "github.com/Azure/mpf/pkg/infrastructure/spRoleAssignmentManager"
35+
"github.com/Azure/mpf/pkg/usecase"
36+
log "github.com/sirupsen/logrus"
37+
"github.com/stretchr/testify/assert"
38+
)
39+
40+
// TestTerraformAuthorizationRequestDenied exercises the Authorization_RequestDenied
41+
// path end-to-end. The sample creates an azuread_group, which requires Microsoft
42+
// Graph application permissions (admin consent / Global Administrator) that MPF
43+
// cannot auto-discover. MPF should fail with a clear guidance error instead of
44+
// silently looping or producing a misleading parse error.
45+
func TestTerraformAuthorizationRequestDenied(t *testing.T) {
46+
mpfArgs, err := getTestingMPFArgs()
47+
if err != nil {
48+
t.Skip("required environment variables not set, skipping end to end test")
49+
}
50+
mpfArgs.MPFMode = "terraform"
51+
52+
if os.Getenv("MPF_TFPATH") == "" {
53+
t.Skip("Terraform Path MPF_TFPATH not set, skipping end to end test")
54+
}
55+
tfpath := os.Getenv("MPF_TFPATH")
56+
57+
_, filename, _, _ := runtime.Caller(0)
58+
curDir := path.Dir(filename)
59+
log.Infof("curDir: %s", curDir)
60+
wrkDir := path.Join(curDir, "../samples/terraform/authorization-request-denied")
61+
log.Infof("wrkDir: %s", wrkDir)
62+
63+
// Clean up Terraform artifacts before and after test
64+
cleanTerraformWorkingDir(t, wrkDir)
65+
t.Cleanup(func() { cleanTerraformWorkingDir(t, wrkDir) })
66+
67+
ctx := t.Context()
68+
69+
mpfConfig := getMPFConfig(mpfArgs)
70+
71+
var rgManager usecase.ResourceGroupManager = rgm.NewResourceGroupManager(mpfArgs.SubscriptionID)
72+
var spRoleAssignmentManager usecase.ServicePrincipalRolemAssignmentManager = spram.NewSPRoleAssignmentManager(mpfArgs.SubscriptionID)
73+
74+
initialPermissionsToAdd := []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"}
75+
permissionsToAddToResult := []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"}
76+
deploymentAuthorizationCheckerCleaner := terraform.NewTerraformAuthorizationChecker(wrkDir, tfpath, "", true, "")
77+
mpfService := usecase.NewMPFService(ctx, rgManager, spRoleAssignmentManager, deploymentAuthorizationCheckerCleaner, mpfConfig, initialPermissionsToAdd, permissionsToAddToResult, false, true, false)
78+
79+
_, err = mpfService.GetMinimumPermissionsRequired()
80+
assert.Error(t, err)
81+
if err != nil {
82+
assert.True(t,
83+
strings.Contains(err.Error(), "Authorization_RequestDenied"),
84+
"expected error to mention Authorization_RequestDenied, got: %v", err)
85+
}
86+
}

pkg/domain/authorizationErrorParser.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,21 @@ import (
3232
log "github.com/sirupsen/logrus"
3333
)
3434

35+
// ErrAuthorizationRequestDenied is returned when an Authorization_RequestDenied error
36+
// is detected from Microsoft Graph / Azure AD and no other parseable Azure RBAC
37+
// authorization errors can be extracted from the error message. Callers can use
38+
// errors.Is to detect this case programmatically.
39+
var ErrAuthorizationRequestDenied = errors.New("Authorization_RequestDenied: insufficient Azure AD / Microsoft Graph API privileges. " +
40+
"These permissions require admin consent or Global Administrator role and cannot be automatically discovered by MPF. " +
41+
"See https://learn.microsoft.com/en-us/graph/permissions-reference for Microsoft Graph permissions " +
42+
"and https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/guides/service_principal_configuration for Terraform AzureAD provider setup")
43+
3544
func GetScopePermissionsFromAuthError(authErrMesg string) (map[string][]string, error) {
3645
log.Debugf("Attempting to Parse Authorization Error: %s", authErrMesg)
37-
if authErrMesg != "" && !strings.Contains(authErrMesg, "AuthorizationFailed") && !strings.Contains(authErrMesg, "Authorization failed") && !strings.Contains(authErrMesg, "AuthorizationPermissionMismatch") && !strings.Contains(authErrMesg, "LinkedAccessCheckFailed") && !strings.Contains(authErrMesg, "LackOfPermissions") {
46+
47+
hasAuthorizationRequestDenied := strings.Contains(authErrMesg, "Authorization_RequestDenied")
48+
49+
if authErrMesg != "" && !hasAuthorizationRequestDenied && !strings.Contains(authErrMesg, "AuthorizationFailed") && !strings.Contains(authErrMesg, "Authorization failed") && !strings.Contains(authErrMesg, "AuthorizationPermissionMismatch") && !strings.Contains(authErrMesg, "LinkedAccessCheckFailed") && !strings.Contains(authErrMesg, "LackOfPermissions") {
3850
log.Warnln("Non Authorization Error when creating deployment:", authErrMesg)
3951
return nil, errors.New("could not parse deployment error, potentially due to a non-authorization error")
4052
}
@@ -109,6 +121,13 @@ func GetScopePermissionsFromAuthError(authErrMesg string) (map[string][]string,
109121

110122
// If map is empty, return error
111123
if len(resMap) == 0 {
124+
// If the original error contained Authorization_RequestDenied and nothing
125+
// else parseable was found, surface the dedicated guidance message so the
126+
// user knows this requires admin consent / Global Administrator privileges.
127+
if hasAuthorizationRequestDenied {
128+
log.Warnln("Authorization_RequestDenied error detected. This error originates from Microsoft Graph / Azure AD and cannot be resolved by MPF.")
129+
return nil, ErrAuthorizationRequestDenied
130+
}
112131
return nil, fmt.Errorf("could not parse deployment error for scope/permissions: %s", authErrMesg)
113132
}
114133

pkg/domain/authorizationErrorParser_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ package domain
2424

2525
// test parseMultiAuthorizationFailedErrors
2626
import (
27+
"errors"
2728
"testing"
2829

2930
"github.com/stretchr/testify/assert"
@@ -85,3 +86,41 @@ func TestMultiAuthorizationSpaceFailedErrors(t *testing.T) {
8586
assert.Equal(t, "Microsoft.KeyVault/vaults/write", lastMatch[0])
8687

8788
}
89+
90+
func TestAuthorizationRequestDeniedError(t *testing.T) {
91+
authRequestDeniedError := `Error: Creating group "Group-name-axtwb"
92+
93+
with azuread_group.res_ds_group[0],
94+
on rbac.tf line 3, in resource "azuread_group" "res_ds_group":
95+
3: resource "azuread_group" "res_ds_group" {
96+
97+
GroupsClient.BaseClient.Post(): unexpected status 403 with OData error:
98+
Authorization_RequestDenied: Insufficient privileges to complete the operation.`
99+
spm, err := GetScopePermissionsFromAuthError(authRequestDeniedError)
100+
assert.NotNil(t, err)
101+
assert.Nil(t, spm)
102+
assert.Contains(t, err.Error(), "Authorization_RequestDenied")
103+
assert.Contains(t, err.Error(), "Azure AD / Microsoft Graph API privileges")
104+
assert.True(t, errors.Is(err, ErrAuthorizationRequestDenied), "expected error to wrap ErrAuthorizationRequestDenied")
105+
}
106+
107+
// TestMixedAuthorizationRequestDeniedAndAuthorizationFailedError ensures that when
108+
// an error message contains both Authorization_RequestDenied and parseable
109+
// AuthorizationFailed entries, MPF still extracts the parseable scope/permissions
110+
// pairs instead of bailing out with the Authorization_RequestDenied guidance.
111+
func TestMixedAuthorizationRequestDeniedAndAuthorizationFailedError(t *testing.T) {
112+
mixedError := `Error: multiple errors occurred during plan/apply:
113+
114+
GroupsClient.BaseClient.Post(): unexpected status 403 with OData error:
115+
Authorization_RequestDenied: Insufficient privileges to complete the operation.
116+
117+
{"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."}}`
118+
119+
spm, err := GetScopePermissionsFromAuthError(mixedError)
120+
assert.Nil(t, err)
121+
assert.NotNil(t, spm)
122+
assert.GreaterOrEqual(t, len(spm), 1)
123+
124+
match := spm["/subscriptions/SSSSSSSS-SSSS-SSSS-SSSS-SSSSSSSSSSSS/resourcegroups/testdeployrg/providers/Microsoft.Storage/storageAccounts/sa1"]
125+
assert.Contains(t, match, "Microsoft.Storage/storageAccounts/write")
126+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
terraform {
2+
required_providers {
3+
azurerm = {
4+
source = "hashicorp/azurerm"
5+
version = "~> 4.0"
6+
}
7+
azuread = {
8+
source = "hashicorp/azuread"
9+
version = "~> 3.0"
10+
}
11+
random = {
12+
source = "hashicorp/random"
13+
version = "~> 3.0"
14+
}
15+
}
16+
}
17+
18+
provider "azurerm" {
19+
features {}
20+
}
21+
22+
provider "azuread" {}
23+
24+
# This sample is intentionally minimal and is used to exercise the
25+
# Authorization_RequestDenied error path in MPF. Creating an Azure AD group
26+
# requires Microsoft Graph application permissions (e.g. Group.Create) that
27+
# require admin consent or Global Administrator role; these cannot be
28+
# auto-discovered by MPF, so MPF should surface a clear guidance error.
29+
resource "random_string" "rand" {
30+
length = 8
31+
special = false
32+
numeric = false
33+
upper = false
34+
lower = true
35+
}
36+
37+
resource "azuread_group" "mpf_test" {
38+
display_name = "mpf-authreqdenied-${random_string.rand.result}"
39+
security_enabled = true
40+
}

0 commit comments

Comments
 (0)