diff --git a/cmd/armCmd.go b/cmd/armCmd.go index 7ede781..5f6a7b1 100644 --- a/cmd/armCmd.go +++ b/cmd/armCmd.go @@ -167,6 +167,8 @@ func getMPFARM(cmd *cobra.Command, args []string) { } displayResult(mpfResult, displayOptions) + + suggestAndDisplayRoles(ctx, mpfConfig.SubscriptionID, mpfResult) } func getDislayOptions(flgShowDetailedOutput bool, flgJSONOutput bool, subscriptionID string) presentation.DisplayOptions { diff --git a/cmd/bicepCmd.go b/cmd/bicepCmd.go index 55babd3..fc5e405 100644 --- a/cmd/bicepCmd.go +++ b/cmd/bicepCmd.go @@ -230,4 +230,5 @@ func getMPFBicep(cmd *cobra.Command, args []string) { displayResult(mpfResult, displayOptions) + suggestAndDisplayRoles(ctx, mpfConfig.SubscriptionID, mpfResult) } diff --git a/cmd/roleSuggestion.go b/cmd/roleSuggestion.go new file mode 100644 index 0000000..bee038d --- /dev/null +++ b/cmd/roleSuggestion.go @@ -0,0 +1,82 @@ +// 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 main + +import ( + "context" + "os" + + "github.com/Azure/mpf/pkg/domain" + roledefinitionmanager "github.com/Azure/mpf/pkg/infrastructure/roleDefinitionManager" + "github.com/Azure/mpf/pkg/presentation" + + log "github.com/sirupsen/logrus" +) + +// suggestAndDisplayRoles fetches the Azure built-in role definitions, matches them +// against the required permissions discovered by MPF, and displays the suggested +// role(s). It is a no-op unless the --suggestRoles flag is set. Failures are +// logged but do not abort the command, since the primary permissions result has +// already been displayed. +func suggestAndDisplayRoles(ctx context.Context, subscriptionID string, mpfResult domain.MPFResult) { + if !flgSuggestRoles { + return + } + + requiredPermissions := flattenRequiredPermissions(mpfResult.RequiredPermissions) + if len(requiredPermissions) == 0 { + log.Warnln("No permissions available to suggest built-in roles for") + return + } + + log.Infoln("Fetching Azure built-in role definitions to suggest matching roles...") + roleProvider := roledefinitionmanager.NewRoleDefinitionManager(subscriptionID) + builtInRoles, err := roleProvider.GetBuiltInRoles(ctx, subscriptionID) + if err != nil { + log.Errorf("Error fetching built-in roles for role suggestion: %v", err) + return + } + + suggestion := domain.SuggestBuiltInRoles(requiredPermissions, builtInRoles) + + if err := presentation.DisplayRoleSuggestion(os.Stdout, suggestion, flgJSONOutput); err != nil { + log.Errorf("Error displaying role suggestion: %v", err) + } +} + +// flattenRequiredPermissions collects the unique permissions across all scopes in +// the MPF result, since a role assignment grants the union of these permissions. +func flattenRequiredPermissions(requiredPermissions map[string][]string) []string { + seen := make(map[string]bool) + var all []string + for _, perms := range requiredPermissions { + for _, perm := range perms { + if perm == "" || seen[perm] { + continue + } + seen[perm] = true + all = append(all, perm) + } + } + return all +} diff --git a/cmd/rootCmd.go b/cmd/rootCmd.go index bfcf871..d61366a 100644 --- a/cmd/rootCmd.go +++ b/cmd/rootCmd.go @@ -54,6 +54,7 @@ var ( flgVerbose bool flgDebug bool flgInitialPermissions string + flgSuggestRoles bool // RootCmd *cobra.Command ) @@ -88,6 +89,7 @@ func NewRootCommand() *cobra.Command { rootCmd.PersistentFlags().BoolVarP(&flgVerbose, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVarP(&flgDebug, "debug", "d", false, "debug output") 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\"]}}.") + rootCmd.PersistentFlags().BoolVarP(&flgSuggestRoles, "suggestRoles", "", false, "After computing the minimum permissions, suggest Azure built-in role(s) that cover them") err := rootCmd.MarkPersistentFlagRequired("subscriptionID") if err != nil { diff --git a/cmd/terraformCmd.go b/cmd/terraformCmd.go index 95a2c35..c221615 100644 --- a/cmd/terraformCmd.go +++ b/cmd/terraformCmd.go @@ -178,4 +178,5 @@ func getMPFTerraform(cmd *cobra.Command, args []string) { displayResult(mpfResult, displayOptions) + suggestAndDisplayRoles(ctx, mpfConfig.SubscriptionID, mpfResult) } diff --git a/docs/commandline-flags-and-env-variables.md b/docs/commandline-flags-and-env-variables.md index eb8a4e7..a62d752 100644 --- a/docs/commandline-flags-and-env-variables.md +++ b/docs/commandline-flags-and-env-variables.md @@ -4,18 +4,19 @@ ## Global Flags (Common to all providers) -| Flag | Environment Variable | Required / Optional | Description | -|--------------------|------------------------|---------------------|-----------------------------------------------------------------------------------------------------------------------------------| -| subscriptionID | MPF_SUBSCRIPTIONID | Required | | -| tenantID | MPF_TENANTID | Required | | -| spClientID | MPF_SPCLIENTID | Required | | -| spObjectID | MPF_SPOBJECTID | Required | Note this is the SP Object id and is different from the Client ID | -| spClientSecret | MPF_SPCLIENTSECRET | Required | | -| 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 | -| jsonOutput | MPF_JSONOUTPUT | Optional | If set to true, the detailed output is printed in JSON format | -| verbose | MPF_VERBOSE | Optional | If set to true, verbose output with informational messages is displayed | -| debug | MPF_DEBUG | Optional | If set to true, output with detailed debug messages is displayed. The debug messages may contain sensitive tokens | -| initialPermissions | MPF_INITIALPERMISSIONS | Optional | Initial permissions to seed the custom role with before MPF analysis. See [Initial Permissions](#initial-permissions) for details | +| Flag | Environment Variable | Required / Optional | Description | +|--------------------|------------------------|---------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| subscriptionID | MPF_SUBSCRIPTIONID | Required | | +| tenantID | MPF_TENANTID | Required | | +| spClientID | MPF_SPCLIENTID | Required | | +| spObjectID | MPF_SPOBJECTID | Required | Note this is the SP Object id and is different from the Client ID | +| spClientSecret | MPF_SPCLIENTSECRET | Required | | +| 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 | +| jsonOutput | MPF_JSONOUTPUT | Optional | If set to true, the detailed output is printed in JSON format | +| verbose | MPF_VERBOSE | Optional | If set to true, verbose output with informational messages is displayed | +| debug | MPF_DEBUG | Optional | If set to true, output with detailed debug messages is displayed. The debug messages may contain sensitive tokens | +| initialPermissions | MPF_INITIALPERMISSIONS | Optional | Initial permissions to seed the custom role with before MPF analysis. See [Initial Permissions](#initial-permissions) for details | +| suggestRoles | MPF_SUGGESTROLES | Optional | If set to true, after computing the minimum permissions MPF suggests Azure built-in role(s) that cover them. See [Suggest Built-In Roles](#suggest-built-in-roles) for details | When used for Terraform, the verbose and debug flags show detailed logs from Terraform. @@ -88,6 +89,30 @@ terraform init The `--targetModule` value follows Terraform's module address syntax (e.g., `module.law`). You can combine this with other flags like `--jsonOutput` or `--initialPermissions`. +## Suggest Built-In Roles + +The `--suggestRoles` flag makes MPF, after it has computed the minimum permissions, query the Azure built-in role definitions and suggest which built-in role(s) cover those permissions. This helps when you would rather assign an existing built-in role than create a custom role. + +The suggestion output contains three parts: + +- **Single-role matches**: built-in roles that each cover every required permission on their own. They are ordered from most specific (least privilege) to broadest, so narrowly scoped roles appear first and broad roles such as `Contributor` and `Owner` appear last. +- **Minimal combination**: a small set of built-in roles that together cover the required permissions, chosen with a greedy least-privilege heuristic. This is useful when no single built-in role covers everything. +- **Uncovered permissions**: any required permissions that no built-in role grants. When present, a custom role is still required for those. + +### Usage + +```bash +azmpf arm \ + --templateFilePath ./template.json \ + --parametersFilePath ./parameters.json \ + --suggestRoles \ + # ... other flags +``` + +Use `--suggestRoles --jsonOutput` to receive the suggestion as JSON for further processing. + +> Note: The suggestion is based on the control-plane actions discovered by MPF and Azure's built-in role definitions at the time the command runs. Always review the suggested role's full permission set before assigning it. + ## Initial Permissions 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: diff --git a/pkg/domain/roleSuggestion.go b/pkg/domain/roleSuggestion.go new file mode 100644 index 0000000..a10de99 --- /dev/null +++ b/pkg/domain/roleSuggestion.go @@ -0,0 +1,321 @@ +// 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 domain + +import ( + "regexp" + "sort" + "strings" +) + +// BuiltInRole represents an Azure built-in role definition and the control-plane +// actions (and notActions) it grants. It is used to suggest which built-in +// role(s) cover the minimum permissions discovered by MPF. +type BuiltInRole struct { + // RoleName is the human readable role name, e.g. "Storage Account Contributor". + RoleName string + // RoleDefinitionID is the role definition GUID. + RoleDefinitionID string + // Actions are the control-plane actions granted by the role. + Actions []string + // NotActions are the control-plane actions explicitly excluded from the role. + NotActions []string +} + +// SuggestedRole is a built-in role paired with the subset of the required +// permissions that it covers. +type SuggestedRole struct { + Role BuiltInRole + // CoveredPermissions are the required permissions covered by this role, sorted. + CoveredPermissions []string +} + +// RoleSuggestion is the result of matching a set of required permissions against +// the available Azure built-in roles. +type RoleSuggestion struct { + // SingleRoleMatches are built-in roles that individually cover every required + // permission. They are ordered from most specific (fewest granted actions) to + // least specific, so least-privilege options appear first. + SingleRoleMatches []SuggestedRole + // MinimalCombination is a small set of built-in roles that together cover as + // many of the required permissions as possible, computed with a greedy + // set-cover heuristic. When a single role covers everything it contains that + // one role. + MinimalCombination []SuggestedRole + // UncoveredPermissions are required permissions not covered by any built-in + // role. When non-empty, a custom role is required for full coverage. + UncoveredPermissions []string +} + +// SuggestBuiltInRoles matches the given required permissions against the provided +// built-in roles and returns single-role matches, a minimal combination, and any +// permissions that no built-in role covers. +func SuggestBuiltInRoles(requiredPermissions []string, builtInRoles []BuiltInRole) RoleSuggestion { + required := getUniqueSlice(normalizePermissions(requiredPermissions)) + sort.Strings(required) + + suggestion := RoleSuggestion{} + + if len(required) == 0 { + return suggestion + } + + // Precompute the set of required permissions each role covers. + roleCoverage := make(map[int][]string, len(builtInRoles)) + for i, role := range builtInRoles { + var covered []string + for _, perm := range required { + if roleCoversPermission(role, perm) { + covered = append(covered, perm) + } + } + if len(covered) > 0 { + sort.Strings(covered) + roleCoverage[i] = covered + } + } + + // Single-role matches: roles that cover every required permission. + for i, covered := range roleCoverage { + if len(covered) == len(required) { + suggestion.SingleRoleMatches = append(suggestion.SingleRoleMatches, SuggestedRole{ + Role: builtInRoles[i], + CoveredPermissions: covered, + }) + } + } + sortSuggestedRolesBySpecificity(suggestion.SingleRoleMatches) + + suggestion.MinimalCombination, suggestion.UncoveredPermissions = greedyRoleCombination(required, builtInRoles, roleCoverage) + + return suggestion +} + +// greedyRoleCombination computes a small set of roles that together cover as many +// required permissions as possible. At each step it selects the role covering the +// most currently-uncovered permissions, breaking ties in favor of the more +// specific role (fewest total granted actions) and then by name for determinism. +func greedyRoleCombination(required []string, builtInRoles []BuiltInRole, roleCoverage map[int][]string) ([]SuggestedRole, []string) { + remaining := make(map[string]bool, len(required)) + for _, perm := range required { + remaining[perm] = true + } + + var combination []SuggestedRole + usedRoles := make(map[int]bool) + + for len(remaining) > 0 { + bestIdx := -1 + var bestNewlyCovered []string + + for i := range builtInRoles { + if usedRoles[i] { + continue + } + covered, ok := roleCoverage[i] + if !ok { + continue + } + + var newlyCovered []string + for _, perm := range covered { + if remaining[perm] { + newlyCovered = append(newlyCovered, perm) + } + } + if len(newlyCovered) == 0 { + continue + } + + if bestIdx == -1 || isBetterGreedyChoice(builtInRoles[i], len(newlyCovered), builtInRoles[bestIdx], len(bestNewlyCovered)) { + bestIdx = i + bestNewlyCovered = newlyCovered + } + } + + if bestIdx == -1 { + // No remaining role covers any of the leftover permissions. + break + } + + sort.Strings(bestNewlyCovered) + combination = append(combination, SuggestedRole{ + Role: builtInRoles[bestIdx], + CoveredPermissions: bestNewlyCovered, + }) + usedRoles[bestIdx] = true + for _, perm := range bestNewlyCovered { + delete(remaining, perm) + } + } + + uncovered := make([]string, 0, len(remaining)) + for perm := range remaining { + uncovered = append(uncovered, perm) + } + sort.Strings(uncovered) + + return combination, uncovered +} + +// isBetterGreedyChoice reports whether candidate is a better greedy pick than the +// current best. More newly-covered permissions wins; ties prefer the more +// specific (least-privilege) role by breadth score, then the lexicographically +// smaller name. +func isBetterGreedyChoice(candidate BuiltInRole, candidateNew int, best BuiltInRole, bestNew int) bool { + if candidateNew != bestNew { + return candidateNew > bestNew + } + candidateBreadth := roleBreadthScore(candidate) + bestBreadth := roleBreadthScore(best) + if candidateBreadth != bestBreadth { + return candidateBreadth < bestBreadth + } + return candidate.RoleName < best.RoleName +} + +// sortSuggestedRolesBySpecificity orders roles from most specific (narrowest +// breadth score) to least specific, so least-privilege suggestions appear first +// and broad roles such as Contributor and Owner appear last. +func sortSuggestedRolesBySpecificity(roles []SuggestedRole) { + sort.SliceStable(roles, func(i, j int) bool { + bi := roleBreadthScore(roles[i].Role) + bj := roleBreadthScore(roles[j].Role) + if bi != bj { + return bi < bj + } + return roles[i].Role.RoleName < roles[j].Role.RoleName + }) +} + +const ( + // globalWildcardWeight is the breadth contribution of the "*" action, which + // grants every control-plane operation (as in Owner/Contributor). + globalWildcardWeight int64 = 1_000_000 + // wildcardWeight is the breadth contribution of a scoped wildcard action such + // as "Microsoft.Storage/*" or "*/read". + wildcardWeight int64 = 1_000 + // exactActionWeight is the breadth contribution of a single, fully qualified + // action with no wildcards. + exactActionWeight int64 = 1 +) + +// roleBreadthScore estimates how broad a role's granted permissions are. Lower +// scores indicate more specific (least-privilege) roles. It intentionally ranks +// a role that grants "*" (a single but all-encompassing action) as far broader +// than a role that lists many narrowly scoped actions. +func roleBreadthScore(role BuiltInRole) int64 { + var score int64 + for _, action := range role.Actions { + score += actionBreadthWeight(action) + } + if score == 0 { + // A role with no usable actions should not be treated as the most specific. + return globalWildcardWeight + } + return score +} + +func actionBreadthWeight(action string) int64 { + action = normalizePermission(action) + switch { + case action == "": + return 0 + case action == "*": + return globalWildcardWeight + case strings.Contains(action, "*"): + return wildcardWeight + default: + return exactActionWeight + } +} + +// roleCoversPermission reports whether the role grants the given permission: the +// permission must match at least one Action pattern and must not match any +// NotAction pattern. +func roleCoversPermission(role BuiltInRole, permission string) bool { + permission = normalizePermission(permission) + if permission == "" { + return false + } + + matched := false + for _, action := range role.Actions { + if actionMatchesPattern(normalizePermission(action), permission) { + matched = true + break + } + } + if !matched { + return false + } + + for _, notAction := range role.NotActions { + if actionMatchesPattern(normalizePermission(notAction), permission) { + return false + } + } + return true +} + +// actionMatchesPattern reports whether an Azure action pattern (which may contain +// '*' wildcards matching any sequence of characters) matches the given action. +// Matching is case-insensitive, consistent with Azure RBAC evaluation. +func actionMatchesPattern(pattern string, action string) bool { + if pattern == "" { + return false + } + if !strings.Contains(pattern, "*") { + return strings.EqualFold(pattern, action) + } + + var sb strings.Builder + sb.WriteString("(?i)^") + for _, segment := range strings.Split(pattern, "*") { + sb.WriteString(regexp.QuoteMeta(segment)) + sb.WriteString(".*") + } + // Remove the trailing ".*" added after the final segment and anchor the end. + regexStr := strings.TrimSuffix(sb.String(), ".*") + "$" + + re, err := regexp.Compile(regexStr) + if err != nil { + return false + } + return re.MatchString(action) +} + +func normalizePermissions(permissions []string) []string { + normalized := make([]string, 0, len(permissions)) + for _, perm := range permissions { + perm = normalizePermission(perm) + if perm != "" { + normalized = append(normalized, perm) + } + } + return normalized +} + +func normalizePermission(permission string) string { + return strings.TrimSpace(permission) +} diff --git a/pkg/domain/roleSuggestion_test.go b/pkg/domain/roleSuggestion_test.go new file mode 100644 index 0000000..b38c7d7 --- /dev/null +++ b/pkg/domain/roleSuggestion_test.go @@ -0,0 +1,242 @@ +// 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 domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestActionMatchesPattern(t *testing.T) { + tests := []struct { + name string + pattern string + action string + expected bool + }{ + {"exact match", "Microsoft.Storage/storageAccounts/read", "Microsoft.Storage/storageAccounts/read", true}, + {"exact mismatch", "Microsoft.Storage/storageAccounts/read", "Microsoft.Storage/storageAccounts/write", false}, + {"case insensitive exact", "microsoft.storage/storageaccounts/read", "Microsoft.Storage/storageAccounts/read", true}, + {"global wildcard", "*", "Microsoft.Storage/storageAccounts/read", true}, + {"provider wildcard match", "Microsoft.Storage/*", "Microsoft.Storage/storageAccounts/read", true}, + {"provider wildcard mismatch", "Microsoft.Storage/*", "Microsoft.Compute/virtualMachines/read", false}, + {"suffix wildcard read match", "*/read", "Microsoft.Storage/storageAccounts/read", true}, + {"suffix wildcard read mismatch", "*/read", "Microsoft.Storage/storageAccounts/write", false}, + {"middle wildcard match", "Microsoft.Compute/*/read", "Microsoft.Compute/virtualMachines/read", true}, + {"middle wildcard mismatch action", "Microsoft.Compute/*/read", "Microsoft.Compute/virtualMachines/write", false}, + {"empty pattern", "", "Microsoft.Storage/storageAccounts/read", false}, + {"resource type wildcard", "Microsoft.Storage/storageAccounts/*", "Microsoft.Storage/storageAccounts/blobServices/read", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, actionMatchesPattern(tt.pattern, tt.action)) + }) + } +} + +func TestRoleCoversPermission(t *testing.T) { + contributorLike := BuiltInRole{ + RoleName: "Contributor-like", + Actions: []string{"*"}, + NotActions: []string{"Microsoft.Authorization/*/write", "Microsoft.Authorization/*/delete"}, + } + + tests := []struct { + name string + role BuiltInRole + permission string + expected bool + }{ + { + name: "action covered no notactions", + role: BuiltInRole{Actions: []string{"Microsoft.Storage/*"}}, + permission: "Microsoft.Storage/storageAccounts/read", + expected: true, + }, + { + name: "action not covered", + role: BuiltInRole{Actions: []string{"Microsoft.Storage/*"}}, + permission: "Microsoft.Compute/virtualMachines/read", + expected: false, + }, + { + name: "wildcard action but excluded by notaction", + role: contributorLike, + permission: "Microsoft.Authorization/roleAssignments/write", + expected: false, + }, + { + name: "wildcard action not excluded", + role: contributorLike, + permission: "Microsoft.Storage/storageAccounts/write", + expected: true, + }, + { + name: "empty permission", + role: BuiltInRole{Actions: []string{"*"}}, + permission: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, roleCoversPermission(tt.role, tt.permission)) + }) + } +} + +func TestSuggestBuiltInRoles_SingleRoleMatch(t *testing.T) { + required := []string{ + "Microsoft.Storage/storageAccounts/read", + "Microsoft.Storage/storageAccounts/write", + } + roles := []BuiltInRole{ + {RoleName: "Owner", Actions: []string{"*"}}, + {RoleName: "Storage Account Contributor", Actions: []string{"Microsoft.Storage/storageAccounts/*", "Microsoft.Insights/read"}}, + {RoleName: "Reader", Actions: []string{"*/read"}}, + } + + suggestion := SuggestBuiltInRoles(required, roles) + + // Owner and Storage Account Contributor both cover all; Reader does not (no write). + assert.Len(t, suggestion.SingleRoleMatches, 2) + // Storage Account Contributor is narrower (scoped wildcards) than Owner ("*"), + // so it ranks first as the least-privilege option. + assert.Equal(t, "Storage Account Contributor", suggestion.SingleRoleMatches[0].Role.RoleName) + assert.Equal(t, "Owner", suggestion.SingleRoleMatches[1].Role.RoleName) + assert.Empty(t, suggestion.UncoveredPermissions) +} + +func TestSuggestBuiltInRoles_SpecificityOrdering(t *testing.T) { + required := []string{"Microsoft.Storage/storageAccounts/read"} + roles := []BuiltInRole{ + {RoleName: "Owner", Actions: []string{"*"}}, + {RoleName: "Broad", Actions: []string{"Microsoft.Storage/*", "a", "b", "c", "d"}}, + {RoleName: "Specific", Actions: []string{"Microsoft.Storage/storageAccounts/read"}}, + } + + suggestion := SuggestBuiltInRoles(required, roles) + + assert.Len(t, suggestion.SingleRoleMatches, 3) + // Ordering is by breadth score (lower = narrower) first: + // Specific (one exact action = 1), Broad (one scoped wildcard + 4 exact = 1004), + // Owner (global "*" = 1,000,000). + assert.Equal(t, "Specific", suggestion.SingleRoleMatches[0].Role.RoleName) + assert.Equal(t, "Broad", suggestion.SingleRoleMatches[1].Role.RoleName) + assert.Equal(t, "Owner", suggestion.SingleRoleMatches[2].Role.RoleName) +} + +func TestSuggestBuiltInRoles_MinimalCombination(t *testing.T) { + required := []string{ + "Microsoft.Storage/storageAccounts/read", + "Microsoft.Compute/virtualMachines/read", + } + roles := []BuiltInRole{ + {RoleName: "Storage Reader", Actions: []string{"Microsoft.Storage/*"}}, + {RoleName: "Compute Reader", Actions: []string{"Microsoft.Compute/*"}}, + } + + suggestion := SuggestBuiltInRoles(required, roles) + + assert.Empty(t, suggestion.SingleRoleMatches) + assert.Len(t, suggestion.MinimalCombination, 2) + assert.Empty(t, suggestion.UncoveredPermissions) + + // Both required permissions are covered exactly once across the combination. + var covered []string + for _, sr := range suggestion.MinimalCombination { + covered = append(covered, sr.CoveredPermissions...) + } + assert.ElementsMatch(t, required, covered) +} + +func TestSuggestBuiltInRoles_PrefersSingleBroadRoleOverTwo(t *testing.T) { + required := []string{ + "Microsoft.Storage/storageAccounts/read", + "Microsoft.Compute/virtualMachines/read", + } + roles := []BuiltInRole{ + {RoleName: "Storage Reader", Actions: []string{"Microsoft.Storage/*"}}, + {RoleName: "Compute Reader", Actions: []string{"Microsoft.Compute/*"}}, + {RoleName: "Owner", Actions: []string{"*"}}, + } + + suggestion := SuggestBuiltInRoles(required, roles) + + // Owner covers everything so it is a single-role match. + assert.Len(t, suggestion.SingleRoleMatches, 1) + assert.Equal(t, "Owner", suggestion.SingleRoleMatches[0].Role.RoleName) + // Greedy combination picks the single role covering the most (Owner covers both). + assert.Len(t, suggestion.MinimalCombination, 1) + assert.Equal(t, "Owner", suggestion.MinimalCombination[0].Role.RoleName) +} + +func TestSuggestBuiltInRoles_UncoveredPermissions(t *testing.T) { + required := []string{ + "Microsoft.Storage/storageAccounts/read", + "Microsoft.CustomProvider/customResource/read", + } + roles := []BuiltInRole{ + {RoleName: "Storage Reader", Actions: []string{"Microsoft.Storage/*"}}, + } + + suggestion := SuggestBuiltInRoles(required, roles) + + assert.Empty(t, suggestion.SingleRoleMatches) + assert.Len(t, suggestion.MinimalCombination, 1) + assert.Equal(t, []string{"Microsoft.CustomProvider/customResource/read"}, suggestion.UncoveredPermissions) +} + +func TestSuggestBuiltInRoles_EmptyInputs(t *testing.T) { + // No required permissions. + suggestion := SuggestBuiltInRoles([]string{}, []BuiltInRole{{RoleName: "Owner", Actions: []string{"*"}}}) + assert.Empty(t, suggestion.SingleRoleMatches) + assert.Empty(t, suggestion.MinimalCombination) + assert.Empty(t, suggestion.UncoveredPermissions) + + // No roles. + suggestion = SuggestBuiltInRoles([]string{"Microsoft.Storage/storageAccounts/read"}, nil) + assert.Empty(t, suggestion.SingleRoleMatches) + assert.Empty(t, suggestion.MinimalCombination) + assert.Equal(t, []string{"Microsoft.Storage/storageAccounts/read"}, suggestion.UncoveredPermissions) +} + +func TestSuggestBuiltInRoles_DeduplicatesAndTrims(t *testing.T) { + required := []string{ + "Microsoft.Storage/storageAccounts/read", + " Microsoft.Storage/storageAccounts/read ", + "", + } + roles := []BuiltInRole{ + {RoleName: "Storage Reader", Actions: []string{"Microsoft.Storage/*"}}, + } + + suggestion := SuggestBuiltInRoles(required, roles) + + assert.Len(t, suggestion.SingleRoleMatches, 1) + assert.Len(t, suggestion.SingleRoleMatches[0].CoveredPermissions, 1) + assert.Empty(t, suggestion.UncoveredPermissions) +} diff --git a/pkg/infrastructure/azureAPI/azureApiClient.go b/pkg/infrastructure/azureAPI/azureApiClient.go index 8a3ffc5..5b96796 100644 --- a/pkg/infrastructure/azureAPI/azureApiClient.go +++ b/pkg/infrastructure/azureAPI/azureApiClient.go @@ -38,9 +38,9 @@ import ( type AzureAPIClients struct { RoleAssignmentsClient *armauthorization.RoleAssignmentsClient RoleAssignmentsDeletionClient *armauthorization.RoleAssignmentsClient - // RoleDefinitionsClient authorization.RoleDefinitionsClient - DeploymentsClient *armresources.DeploymentsClient - ResourceGroupsClient *armresources.ResourceGroupsClient + RoleDefinitionsClient *armauthorization.RoleDefinitionsClient + DeploymentsClient *armresources.DeploymentsClient + ResourceGroupsClient *armresources.ResourceGroupsClient // Default CLI Creds CLICred *azidentity.AzureCLICredential @@ -100,9 +100,11 @@ func (a *AzureAPIClients) SetApiClients(subscriptionId string) error { log.Fatalf("failed to create role assignments deletion client: %v", err) } - // Set RoleDefinitionsClient - // a.RoleDefinitionsClient = authorization.NewRoleDefinitionsClient(subscriptionId) - // a.RoleDefinitionsClient.Authorizer = authorizer + // Set RoleDefinitionsClient (scope based, used to enumerate built-in roles) + a.RoleDefinitionsClient, err = armauthorization.NewRoleDefinitionsClient(a.DefaultCred, nil) + if err != nil { + log.Fatalf("failed to create role definitions client: %v", err) + } resourcesClientFactory, err := armresources.NewClientFactory(subscriptionId, a.DefaultCred, nil) if err != nil { diff --git a/pkg/infrastructure/roleDefinitionManager/roleDefinitionManager.go b/pkg/infrastructure/roleDefinitionManager/roleDefinitionManager.go new file mode 100644 index 0000000..93352d6 --- /dev/null +++ b/pkg/infrastructure/roleDefinitionManager/roleDefinitionManager.go @@ -0,0 +1,110 @@ +// 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 roledefinitionmanager + +import ( + "context" + "fmt" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3" + "github.com/Azure/mpf/pkg/domain" + "github.com/Azure/mpf/pkg/infrastructure/azureAPI" + + log "github.com/sirupsen/logrus" +) + +// RoleDefinitionManager retrieves Azure built-in role definitions so that MPF can +// suggest which built-in role(s) cover the required permissions. +type RoleDefinitionManager struct { + azAPIClient *azureAPI.AzureAPIClients +} + +func NewRoleDefinitionManager(subscriptionID string) *RoleDefinitionManager { + azAPIClient := azureAPI.NewAzureAPIClients(subscriptionID) + return &RoleDefinitionManager{ + azAPIClient: azAPIClient, + } +} + +// GetBuiltInRoles lists all built-in role definitions at the subscription scope +// and returns them with their control-plane actions and notActions. +func (r *RoleDefinitionManager) GetBuiltInRoles(ctx context.Context, subscriptionID string) ([]domain.BuiltInRole, error) { + scope := fmt.Sprintf("/subscriptions/%s", subscriptionID) + + pager := r.azAPIClient.RoleDefinitionsClient.NewListPager(scope, &armauthorization.RoleDefinitionsClientListOptions{ + Filter: to("type eq 'BuiltInRole'"), + }) + + var roles []domain.BuiltInRole + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list built-in role definitions: %w", err) + } + + for _, roleDef := range page.Value { + if roleDef == nil || roleDef.Properties == nil { + continue + } + + role := domain.BuiltInRole{ + RoleName: derefString(roleDef.Properties.RoleName), + RoleDefinitionID: derefString(roleDef.Name), + } + + for _, perm := range roleDef.Properties.Permissions { + if perm == nil { + continue + } + role.Actions = append(role.Actions, derefStringSlice(perm.Actions)...) + role.NotActions = append(role.NotActions, derefStringSlice(perm.NotActions)...) + } + + roles = append(roles, role) + } + } + + log.Debugf("Retrieved %d built-in role definitions", len(roles)) + return roles, nil +} + +func to(s string) *string { + return &s +} + +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +func derefStringSlice(s []*string) []string { + result := make([]string, 0, len(s)) + for _, item := range s { + if item != nil { + result = append(result, *item) + } + } + return result +} diff --git a/pkg/infrastructure/roleDefinitionManager/roleDefinitionManager_integration_test.go b/pkg/infrastructure/roleDefinitionManager/roleDefinitionManager_integration_test.go new file mode 100644 index 0000000..9cb66a1 --- /dev/null +++ b/pkg/infrastructure/roleDefinitionManager/roleDefinitionManager_integration_test.go @@ -0,0 +1,70 @@ +// 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 roledefinitionmanager + +import ( + "context" + "os" + "testing" + + "github.com/Azure/mpf/pkg/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetBuiltInRoles_Integration is a read-only integration test that verifies +// the manager can enumerate built-in role definitions from a real subscription. +// It is skipped unless MPF_INTEGRATION_SUBSCRIPTION_ID is set. It creates no +// Azure resources and only performs list operations. +func TestGetBuiltInRoles_Integration(t *testing.T) { + subscriptionID := os.Getenv("MPF_INTEGRATION_SUBSCRIPTION_ID") + if subscriptionID == "" { + t.Skip("MPF_INTEGRATION_SUBSCRIPTION_ID not set; skipping read-only Azure integration test") + } + + mgr := NewRoleDefinitionManager(subscriptionID) + roles, err := mgr.GetBuiltInRoles(context.Background(), subscriptionID) + require.NoError(t, err) + + // Azure ships well over 100 built-in roles. + assert.Greater(t, len(roles), 100, "expected many built-in roles") + + // Every returned role should have a name and at least one action. + var reader *domain.BuiltInRole + for i := range roles { + assert.NotEmpty(t, roles[i].RoleName, "role name should not be empty") + assert.NotEmpty(t, roles[i].RoleDefinitionID, "role definition id should not be empty") + if roles[i].RoleName == "Reader" { + reader = &roles[i] + } + } + + // The built-in Reader role should exist and grant "*/read". + require.NotNil(t, reader, "expected to find the built-in Reader role") + assert.Contains(t, reader.Actions, "*/read") + + // Sanity check that suggestion works end-to-end against real role data. + suggestion := domain.SuggestBuiltInRoles([]string{"Microsoft.Storage/storageAccounts/read"}, roles) + assert.NotEmpty(t, suggestion.SingleRoleMatches, "Reader (and others) should cover a read permission") + assert.Empty(t, suggestion.UncoveredPermissions) +} diff --git a/pkg/presentation/roleSuggestionFormatter.go b/pkg/presentation/roleSuggestionFormatter.go new file mode 100644 index 0000000..1cc92f7 --- /dev/null +++ b/pkg/presentation/roleSuggestionFormatter.go @@ -0,0 +1,128 @@ +// 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 presentation + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/Azure/mpf/pkg/domain" +) + +// maxSingleRoleMatchesDisplayed caps how many single-role matches are shown in +// text output to keep the suggestion readable. The most specific roles are shown +// first, so broad roles such as Owner and Contributor appear last (or are hidden). +const maxSingleRoleMatchesDisplayed = 10 + +const roleSuggestionSeparator = "------------------------------------------------------------------------------------------------------------------------------------------" + +// DisplayRoleSuggestion writes the built-in role suggestion to w, either as JSON +// (when jsonOutput is true) or as human readable text. +func DisplayRoleSuggestion(w io.Writer, suggestion domain.RoleSuggestion, jsonOutput bool) error { + if jsonOutput { + return displayRoleSuggestionJSON(w, suggestion) + } + return displayRoleSuggestionText(w, suggestion) +} + +func displayRoleSuggestionJSON(w io.Writer, suggestion domain.RoleSuggestion) error { + jsonBytes, err := json.MarshalIndent(suggestion, "", " ") + if err != nil { + return err + } + _, err = w.Write(jsonBytes) + return err +} + +// errWriter wraps an io.Writer and remembers the first write error so callers +// can check it once at the end instead of after every write. +type errWriter struct { + w io.Writer + err error +} + +func (ew *errWriter) println(a ...any) { + if ew.err != nil { + return + } + _, ew.err = fmt.Fprintln(ew.w, a...) +} + +func (ew *errWriter) printf(format string, a ...any) { + if ew.err != nil { + return + } + _, ew.err = fmt.Fprintf(ew.w, format, a...) +} + +func displayRoleSuggestionText(w io.Writer, suggestion domain.RoleSuggestion) error { + ew := &errWriter{w: w} + + ew.println(roleSuggestionSeparator) + ew.println("Suggested Built-In Roles:") + ew.println(roleSuggestionSeparator) + + if len(suggestion.SingleRoleMatches) > 0 { + ew.println("The following built-in role(s) each cover ALL required permissions (most specific first):") + ew.println() + displayCount := len(suggestion.SingleRoleMatches) + if displayCount > maxSingleRoleMatchesDisplayed { + displayCount = maxSingleRoleMatchesDisplayed + } + for _, sr := range suggestion.SingleRoleMatches[:displayCount] { + ew.printf(" - %s (%s)\n", sr.Role.RoleName, sr.Role.RoleDefinitionID) + } + if len(suggestion.SingleRoleMatches) > displayCount { + ew.printf(" ... and %d more\n", len(suggestion.SingleRoleMatches)-displayCount) + } + ew.println() + } else { + ew.println("No single built-in role covers all required permissions.") + ew.println() + } + + if len(suggestion.MinimalCombination) > 0 { + ew.println("Suggested minimal combination of built-in roles to cover the required permissions:") + ew.println() + for _, sr := range suggestion.MinimalCombination { + ew.printf(" - %s (%s) covers %d permission(s):\n", sr.Role.RoleName, sr.Role.RoleDefinitionID, len(sr.CoveredPermissions)) + for _, perm := range sr.CoveredPermissions { + ew.printf(" %s\n", perm) + } + } + ew.println() + } + + if len(suggestion.UncoveredPermissions) > 0 { + ew.println("The following required permissions are NOT covered by any built-in role.") + ew.println("A custom role is required to grant these:") + for _, perm := range suggestion.UncoveredPermissions { + ew.printf(" %s\n", perm) + } + ew.println() + } + + ew.println(roleSuggestionSeparator) + return ew.err +} diff --git a/pkg/presentation/roleSuggestionFormatter_test.go b/pkg/presentation/roleSuggestionFormatter_test.go new file mode 100644 index 0000000..fb53dbe --- /dev/null +++ b/pkg/presentation/roleSuggestionFormatter_test.go @@ -0,0 +1,111 @@ +// 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 presentation + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/Azure/mpf/pkg/domain" + "github.com/stretchr/testify/assert" +) + +func TestDisplayRoleSuggestion_Text_SingleMatch(t *testing.T) { + suggestion := domain.RoleSuggestion{ + SingleRoleMatches: []domain.SuggestedRole{ + { + Role: domain.BuiltInRole{RoleName: "Storage Account Contributor", RoleDefinitionID: "17d1049b-9a84-46fb-8f53-869881c3d3ab"}, + CoveredPermissions: []string{"Microsoft.Storage/storageAccounts/read"}, + }, + }, + } + + var buf bytes.Buffer + err := DisplayRoleSuggestion(&buf, suggestion, false) + assert.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "Suggested Built-In Roles:") + assert.Contains(t, out, "each cover ALL required permissions") + assert.Contains(t, out, "Storage Account Contributor") +} + +func TestDisplayRoleSuggestion_Text_CombinationAndUncovered(t *testing.T) { + suggestion := domain.RoleSuggestion{ + MinimalCombination: []domain.SuggestedRole{ + { + Role: domain.BuiltInRole{RoleName: "Storage Reader", RoleDefinitionID: "id-1"}, + CoveredPermissions: []string{"Microsoft.Storage/storageAccounts/read"}, + }, + }, + UncoveredPermissions: []string{"Microsoft.CustomProvider/customResource/read"}, + } + + var buf bytes.Buffer + err := DisplayRoleSuggestion(&buf, suggestion, false) + assert.NoError(t, err) + + out := buf.String() + assert.Contains(t, out, "No single built-in role covers all required permissions.") + assert.Contains(t, out, "Suggested minimal combination") + assert.Contains(t, out, "Storage Reader") + assert.Contains(t, out, "NOT covered by any built-in role") + assert.Contains(t, out, "Microsoft.CustomProvider/customResource/read") +} + +func TestDisplayRoleSuggestion_JSON(t *testing.T) { + suggestion := domain.RoleSuggestion{ + SingleRoleMatches: []domain.SuggestedRole{ + { + Role: domain.BuiltInRole{RoleName: "Owner", RoleDefinitionID: "id-owner"}, + CoveredPermissions: []string{"Microsoft.Storage/storageAccounts/read"}, + }, + }, + } + + var buf bytes.Buffer + err := DisplayRoleSuggestion(&buf, suggestion, true) + assert.NoError(t, err) + + var decoded domain.RoleSuggestion + err = json.Unmarshal(buf.Bytes(), &decoded) + assert.NoError(t, err) + assert.Len(t, decoded.SingleRoleMatches, 1) + assert.Equal(t, "Owner", decoded.SingleRoleMatches[0].Role.RoleName) +} + +func TestDisplayRoleSuggestion_Text_CapsSingleMatches(t *testing.T) { + var matches []domain.SuggestedRole + for i := 0; i < maxSingleRoleMatchesDisplayed+5; i++ { + matches = append(matches, domain.SuggestedRole{ + Role: domain.BuiltInRole{RoleName: "Role", RoleDefinitionID: "id"}, + }) + } + suggestion := domain.RoleSuggestion{SingleRoleMatches: matches} + + var buf bytes.Buffer + err := DisplayRoleSuggestion(&buf, suggestion, false) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "and 5 more") +} diff --git a/pkg/usecase/roleSuggester.go b/pkg/usecase/roleSuggester.go new file mode 100644 index 0000000..1c32506 --- /dev/null +++ b/pkg/usecase/roleSuggester.go @@ -0,0 +1,36 @@ +// 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 usecase + +import ( + "context" + + "github.com/Azure/mpf/pkg/domain" +) + +// BuiltInRoleProvider retrieves the Azure built-in role definitions available at +// the given subscription scope. It is used to suggest which built-in role(s) +// cover the minimum permissions discovered by MPF. +type BuiltInRoleProvider interface { + GetBuiltInRoles(ctx context.Context, subscriptionID string) ([]domain.BuiltInRole, error) +}