Skip to content

Commit 2c089ee

Browse files
authored
feat: add --initialPermissions flag to reduce execution time (#212)
1 parent aaf76e6 commit 2c089ee

11 files changed

Lines changed: 437 additions & 28 deletions

File tree

cmd/armCmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,9 @@ func getMPFARM(cmd *cobra.Command, args []string) {
146146
initialPermissionsToAdd = []string{"Microsoft.Resources/deployments/*", "Microsoft.Resources/subscriptions/operationresults/read"}
147147
permissionsToAddToResult = []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"}
148148

149+
// Add initial permissions from flag if provided (supports comma-separated string or @file.json)
150+
initialPermissionsToAdd, permissionsToAddToResult = appendUserInitialPermissions(initialPermissionsToAdd, permissionsToAddToResult)
151+
149152
mpfService = usecase.NewMPFService(ctx, rgManager, spRoleAssignmentManager, deploymentAuthorizationCheckerCleaner, mpfConfig, initialPermissionsToAdd, permissionsToAddToResult, true, false, true)
150153

151154
log.Infof("Show Detailed Output: %t\n", flgShowDetailedOutput)

cmd/bicepCmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,9 @@ func getMPFBicep(cmd *cobra.Command, args []string) {
171171
initialPermissionsToAdd = []string{"Microsoft.Resources/deployments/*", "Microsoft.Resources/subscriptions/operationresults/read"}
172172
permissionsToAddToResult = []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"}
173173

174+
// Add initial permissions from flag if provided (supports comma-separated string or @file.json)
175+
initialPermissionsToAdd, permissionsToAddToResult = appendUserInitialPermissions(initialPermissionsToAdd, permissionsToAddToResult)
176+
174177
// Always auto-create resource group since only resource group scoped deployments are supported
175178
var autoCreateResourceGroup = true
176179
mpfService = usecase.NewMPFService(ctx, rgManager, spRoleAssignmentManager, deploymentAuthorizationCheckerCleaner, mpfConfig, initialPermissionsToAdd, permissionsToAddToResult, true, false, autoCreateResourceGroup)

cmd/rootCmd.go

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
package main
2424

2525
import (
26+
"encoding/json"
2627
"fmt"
2728
"os"
2829
"path/filepath"
@@ -43,15 +44,16 @@ var (
4344
envPrefix = "MPF"
4445
replaceHyphenWithCamelCase = false
4546

46-
flgSubscriptionID string
47-
flgTenantID string
48-
flgSPClientID string
49-
flgSPObjectID string
50-
flgSPClientSecret string
51-
flgShowDetailedOutput bool
52-
flgJSONOutput bool
53-
flgVerbose bool
54-
flgDebug bool
47+
flgSubscriptionID string
48+
flgTenantID string
49+
flgSPClientID string
50+
flgSPObjectID string
51+
flgSPClientSecret string
52+
flgShowDetailedOutput bool
53+
flgJSONOutput bool
54+
flgVerbose bool
55+
flgDebug bool
56+
flgInitialPermissions string
5557
// RootCmd *cobra.Command
5658
)
5759

@@ -85,6 +87,7 @@ func NewRootCommand() *cobra.Command {
8587
rootCmd.PersistentFlags().BoolVarP(&flgJSONOutput, "jsonOutput", "", false, "Output in JSON format")
8688
rootCmd.PersistentFlags().BoolVarP(&flgVerbose, "verbose", "v", false, "verbose output")
8789
rootCmd.PersistentFlags().BoolVarP(&flgDebug, "debug", "d", false, "debug output")
90+
rootCmd.PersistentFlags().StringVarP(&flgInitialPermissions, "initialPermissions", "", "", "Initial permissions to add to the custom role before starting MPF analysis. Can be a comma-separated list (e.g., 'perm1,perm2') or @path/to/file.json to load from a JSON file with format: {\"RequiredPermissions\":{\"\":[\"perm1\",\"perm2\"]}}.")
8891

8992
err := rootCmd.MarkPersistentFlagRequired("subscriptionID")
9093
if err != nil {
@@ -208,3 +211,69 @@ func getAbsolutePath(path string) (string, error) {
208211
}
209212
return absPath, nil
210213
}
214+
215+
// parseInitialPermissions parses the initial permissions from either a comma-separated string
216+
// or from a JSON file (if the value starts with @).
217+
// The JSON file should have the same format as .permissionsFromFailedRun.json:
218+
// {"RequiredPermissions":{"":["perm1","perm2"]}}
219+
func parseInitialPermissions(value string) ([]string, error) {
220+
if value == "" {
221+
return nil, nil
222+
}
223+
224+
// Check if it's a file reference (starts with @)
225+
if strings.HasPrefix(value, "@") {
226+
filePath := strings.TrimPrefix(value, "@")
227+
absPath, err := getAbsolutePath(filePath)
228+
if err != nil {
229+
return nil, fmt.Errorf("error getting absolute path for permissions file: %w", err)
230+
}
231+
232+
file, err := os.Open(absPath)
233+
if err != nil {
234+
return nil, fmt.Errorf("error opening permissions file %s: %w", absPath, err)
235+
}
236+
defer file.Close() //nolint:errcheck
237+
238+
var result domain.MPFResult
239+
decoder := json.NewDecoder(file)
240+
if err := decoder.Decode(&result); err != nil {
241+
return nil, fmt.Errorf("error parsing permissions file %s: %w", absPath, err)
242+
}
243+
244+
permissions := result.RequiredPermissions[""]
245+
if len(permissions) == 0 {
246+
log.Warnf("No permissions found in file %s under the empty string key", absPath)
247+
}
248+
return permissions, nil
249+
}
250+
251+
// Parse as comma-separated string
252+
permissions := strings.Split(value, ",")
253+
for i := range permissions {
254+
permissions[i] = strings.TrimSpace(permissions[i])
255+
}
256+
return permissions, nil
257+
}
258+
259+
// appendUserInitialPermissions parses the --initialPermissions flag and appends
260+
// the permissions to both slices. This is a helper to reduce code duplication
261+
// across arm, bicep, and terraform commands.
262+
func appendUserInitialPermissions(initialPermissionsToAdd, permissionsToAddToResult []string) ([]string, []string) {
263+
if flgInitialPermissions == "" {
264+
return initialPermissionsToAdd, permissionsToAddToResult
265+
}
266+
267+
userPermissions, err := parseInitialPermissions(flgInitialPermissions)
268+
if err != nil {
269+
log.Fatalf("Error parsing initial permissions: %v\n", err)
270+
}
271+
272+
if len(userPermissions) > 0 {
273+
log.Infof("Adding user-specified initial permissions: %v\n", userPermissions)
274+
initialPermissionsToAdd = append(initialPermissionsToAdd, userPermissions...)
275+
permissionsToAddToResult = append(permissionsToAddToResult, userPermissions...)
276+
}
277+
278+
return initialPermissionsToAdd, permissionsToAddToResult
279+
}

cmd/rootCmd_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 main
24+
25+
import (
26+
"os"
27+
"path/filepath"
28+
"reflect"
29+
"testing"
30+
)
31+
32+
func TestParseInitialPermissions(t *testing.T) {
33+
tests := []struct {
34+
name string
35+
input string
36+
want []string
37+
wantErr bool
38+
setupFile bool
39+
fileContent string
40+
}{
41+
{
42+
name: "empty string returns nil",
43+
input: "",
44+
want: nil,
45+
wantErr: false,
46+
},
47+
{
48+
name: "single permission",
49+
input: "Microsoft.Storage/storageAccounts/read",
50+
want: []string{"Microsoft.Storage/storageAccounts/read"},
51+
wantErr: false,
52+
},
53+
{
54+
name: "comma-separated permissions",
55+
input: "Microsoft.Storage/storageAccounts/read,Microsoft.Storage/storageAccounts/write",
56+
want: []string{
57+
"Microsoft.Storage/storageAccounts/read",
58+
"Microsoft.Storage/storageAccounts/write",
59+
},
60+
wantErr: false,
61+
},
62+
{
63+
name: "comma-separated permissions with spaces",
64+
input: "Microsoft.Storage/storageAccounts/read, Microsoft.Storage/storageAccounts/write , Microsoft.Storage/storageAccounts/listKeys/action",
65+
want: []string{
66+
"Microsoft.Storage/storageAccounts/read",
67+
"Microsoft.Storage/storageAccounts/write",
68+
"Microsoft.Storage/storageAccounts/listKeys/action",
69+
},
70+
wantErr: false,
71+
},
72+
{
73+
name: "file reference with valid JSON",
74+
input: "@testperms.json",
75+
setupFile: true,
76+
fileContent: `{
77+
"RequiredPermissions": {
78+
"": [
79+
"Microsoft.Storage/storageAccounts/read",
80+
"Microsoft.Storage/storageAccounts/write"
81+
]
82+
}
83+
}`,
84+
want: []string{
85+
"Microsoft.Storage/storageAccounts/read",
86+
"Microsoft.Storage/storageAccounts/write",
87+
},
88+
wantErr: false,
89+
},
90+
{
91+
name: "file reference to non-existent file",
92+
input: "@nonexistent.json",
93+
want: nil,
94+
wantErr: true,
95+
},
96+
{
97+
name: "file reference with invalid JSON",
98+
input: "@invalid.json",
99+
setupFile: true,
100+
fileContent: `not valid json`,
101+
want: nil,
102+
wantErr: true,
103+
},
104+
{
105+
name: "file reference with empty permissions array",
106+
input: "@empty.json",
107+
setupFile: true,
108+
fileContent: `{
109+
"RequiredPermissions": {
110+
"": []
111+
}
112+
}`,
113+
want: []string{},
114+
wantErr: false,
115+
},
116+
}
117+
118+
for _, tt := range tests {
119+
t.Run(tt.name, func(t *testing.T) {
120+
// Setup file if needed
121+
if tt.setupFile {
122+
filename := tt.input[1:] // Remove @ prefix
123+
tmpDir := t.TempDir()
124+
filePath := filepath.Join(tmpDir, filename)
125+
err := os.WriteFile(filePath, []byte(tt.fileContent), 0644)
126+
if err != nil {
127+
t.Fatalf("failed to create test file: %v", err)
128+
}
129+
// Update input to use absolute path
130+
tt.input = "@" + filePath
131+
}
132+
133+
got, err := parseInitialPermissions(tt.input)
134+
if (err != nil) != tt.wantErr {
135+
t.Errorf("parseInitialPermissions() error = %v, wantErr %v", err, tt.wantErr)
136+
return
137+
}
138+
if !reflect.DeepEqual(got, tt.want) {
139+
t.Errorf("parseInitialPermissions() = %v, want %v", got, tt.want)
140+
}
141+
})
142+
}
143+
}

cmd/terraformCmd.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ func getMPFTerraform(cmd *cobra.Command, args []string) {
139139
initialPermissionsToAdd := []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"}
140140
permissionsToAddToResult := []string{"Microsoft.Resources/deployments/read", "Microsoft.Resources/deployments/write"}
141141

142+
// Add initial permissions from flag if provided (supports comma-separated string or @file.json)
143+
initialPermissionsToAdd, permissionsToAddToResult = appendUserInitialPermissions(initialPermissionsToAdd, permissionsToAddToResult)
144+
142145
// Check if permissions file from previous failed run exists
143146
if terraform.DoesTFFileExist(flgWorkingDir, FoundPermissionsFromFailedRunFilename) {
144147
prevResult, err := terraform.LoadMPFResultFromFile(flgWorkingDir, FoundPermissionsFromFailedRunFilename)

docs/commandline-flags-and-env-variables.md

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@
44

55
## Global Flags (Common to all providers)
66

7-
| Flag | Environment Variable | Required / Optional | Description |
8-
|--------------------|------------------------|---------------------|--------------------------------------------------------------------------------------------------------------------------------|
9-
| subscriptionID | MPF_SUBSCRIPTIONID | Required | |
10-
| tenantID | MPF_TENANTID | Required | |
11-
| spClientID | MPF_SPCLIENTID | Required | |
12-
| spObjectID | MPF_SPOBJECTID | Required | Note this is the SP Object id and is different from the Client ID |
13-
| spClientSecret | MPF_SPCLIENTSECRET | Required | |
14-
| showDetailedOutput | MPF_SHOWDETAILEDOUTPUT | Optional | If set to true, the output shows details of permissions resource wise as well. This is not needed if --jsonOutput is specified |
15-
| jsonOutput | MPF_JSONOUTPUT | Optional | If set to true, the detailed output is printed in JSON format |
16-
| verbose | MPF_VERBOSE | Optional | If set to true, verbose output with informational messages is displayed |
17-
| debug | MPF_DEBUG | Optional | If set to true, output with detailed debug messages is displayed. The debug messages may contain sensitive tokens |
7+
| Flag | Environment Variable | Required / Optional | Description |
8+
|--------------------|------------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------|
9+
| subscriptionID | MPF_SUBSCRIPTIONID | Required | |
10+
| tenantID | MPF_TENANTID | Required | |
11+
| spClientID | MPF_SPCLIENTID | Required | |
12+
| spObjectID | MPF_SPOBJECTID | Required | Note this is the SP Object id and is different from the Client ID |
13+
| spClientSecret | MPF_SPCLIENTSECRET | Required | |
14+
| showDetailedOutput | MPF_SHOWDETAILEDOUTPUT | Optional | If set to true, the output shows details of permissions resource wise as well. This is not needed if --jsonOutput is specified |
15+
| jsonOutput | MPF_JSONOUTPUT | Optional | If set to true, the detailed output is printed in JSON format |
16+
| verbose | MPF_VERBOSE | Optional | If set to true, verbose output with informational messages is displayed |
17+
| debug | MPF_DEBUG | Optional | If set to true, output with detailed debug messages is displayed. The debug messages may contain sensitive tokens |
18+
| initialPermissions | MPF_INITIALPERMISSIONS | Optional | Initial permissions to seed the custom role with before MPF analysis. See [Initial Permissions](#initial-permissions) for details |
1819

1920
When used for Terraform, the verbose and debug flags show detailed logs from Terraform.
2021

@@ -48,3 +49,77 @@ When used for Terraform, the verbose and debug flags show detailed logs from Ter
4849
| varFilePath | MPF_VARFILEPATH | Optional | Path to the Terraform variables file |
4950
| importExistingResourcesToState | MPF_IMPORTEXISTINGRESOURCESTOSTATE | Optional | Default Value is true. This is required for some scenarios as described in the [Known Issues - Import Errors](./known-issues-and-workarounds.MD#existing-resource--import-errors) |
5051
| targetModule | MPF_TARGETMODULE | Optional | Target module to be used for the Terraform deployment |
52+
53+
## Initial Permissions
54+
55+
The `--initialPermissions` flag allows you to specify permissions that should be added to the custom role before MPF starts its analysis. This is particularly useful when:
56+
57+
- Using **Terraform with a remote backend** (e.g., Azure Storage) that requires permissions to access the state store
58+
- You want to **reduce MPF execution time** by seeding known permissions upfront
59+
- Your deployment has **prerequisites** that need specific permissions before the main deployment can proceed
60+
61+
### Usage
62+
63+
The flag accepts two formats:
64+
65+
#### 1. Comma-separated list
66+
67+
```bash
68+
azmpf terraform \
69+
--initialPermissions "Microsoft.Storage/storageAccounts/read,Microsoft.Storage/storageAccounts/listKeys/action" \
70+
--workingDir ./my-terraform \
71+
# ... other flags
72+
```
73+
74+
#### 2. JSON file reference (prefix with @)
75+
76+
```bash
77+
azmpf terraform \
78+
--initialPermissions @backend-permissions.json \
79+
--workingDir ./my-terraform \
80+
# ... other flags
81+
```
82+
83+
The JSON file must have the following format:
84+
85+
```json
86+
{
87+
"RequiredPermissions": {
88+
"": [
89+
"Microsoft.Storage/storageAccounts/read",
90+
"Microsoft.Storage/storageAccounts/listKeys/action",
91+
"Microsoft.Storage/storageAccounts/blobServices/containers/read",
92+
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read",
93+
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write"
94+
]
95+
}
96+
}
97+
```
98+
99+
### Example: Terraform Remote Backend
100+
101+
When using Azure Storage as a Terraform remote backend, the service principal needs permissions to access the storage account. Create a file called `backend-permissions.json`:
102+
103+
```json
104+
{
105+
"RequiredPermissions": {
106+
"": [
107+
"Microsoft.Storage/storageAccounts/read",
108+
"Microsoft.Storage/storageAccounts/listKeys/action",
109+
"Microsoft.Storage/storageAccounts/blobServices/containers/read",
110+
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read",
111+
"Microsoft.Storage/storageAccounts/blobServices/containers/blobs/write"
112+
]
113+
}
114+
}
115+
```
116+
117+
Then run MPF with:
118+
119+
```bash
120+
azmpf terraform \
121+
--initialPermissions @backend-permissions.json \
122+
--tfPath $(which terraform) \
123+
--workingDir ./my-terraform \
124+
# ... other required flags
125+
```

0 commit comments

Comments
 (0)