diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 71099ab8ee4..9dddcfa01f6 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -306,6 +306,14 @@ overrides: - filename: pkg/azdext/scope_detector.go words: - fakeazure + - filename: pkg/azapi/policy_service.go + words: + - armpolicy + - disablelocalauth + - allowsharedkeyaccess + - policydefinitions + - policysetdefinitions + - managementgroups - filename: extensions/azure.ai.models/internal/cmd/custom_create.go words: - Qwen diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index c46f2b11b3a..93db06b8600 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -698,6 +698,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // Tools container.MustRegisterSingleton(azapi.NewResourceService) container.MustRegisterSingleton(azapi.NewPermissionsService) + container.MustRegisterSingleton(azapi.NewPolicyService) container.MustRegisterSingleton(docker.NewCli) container.MustRegisterSingleton(dotnet.NewCli) container.MustRegisterSingleton(git.NewCli) diff --git a/cli/azd/go.mod b/cli/azd/go.mod index cdc613c3085..c9e33d81ab4 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -22,6 +22,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/operationalinsights/armoperationalinsights/v2 v2.0.2 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeploymentstacks v1.0.1 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v0.10.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7 diff --git a/cli/azd/go.sum b/cli/azd/go.sum index 14cf7993ed4..ceaaa62530d 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -49,6 +49,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourceg github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0/go.mod h1:wVEOJfGTj0oPAUGA1JuRAvz/lxXQsWW16axmHPP47Bk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeploymentstacks v1.0.1 h1:bcgO/crpp7wqI0Froi/I4C2fme7Vk/WLusbV399Do8I= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armdeploymentstacks v1.0.1/go.mod h1:kvfPmsE8gpOwwC1qrO1FeyBDDNfnwBN5UU3MPNiWW7I= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v0.10.0 h1:FCprRw2Uzske3FiFVGm6MqJY829zrAJLiN4coFueWis= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v0.10.0/go.mod h1:koK4/Mf6lxFkYavGzZnzTUOEmY8ic9tN44UmWZsGfrk= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= diff --git a/cli/azd/pkg/azapi/policy_service.go b/cli/azd/pkg/azapi/policy_service.go new file mode 100644 index 00000000000..4322b407c82 --- /dev/null +++ b/cli/azd/pkg/azapi/policy_service.go @@ -0,0 +1,668 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azapi + +import ( + "context" + "encoding/json" + "fmt" + "log" + "maps" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy" + "github.com/azure/azure-dev/cli/azd/pkg/account" +) + +// LocalAuthDenyPolicy describes a deny policy that requires disableLocalAuth to be true +// for a specific Azure resource type. +type LocalAuthDenyPolicy struct { + // PolicyName is the display name of the policy assignment. + PolicyName string + // ResourceType is the Azure resource type targeted (e.g. "Microsoft.CognitiveServices/accounts"). + ResourceType string + // FieldPath is the full field path checked (e.g. "Microsoft.CognitiveServices/accounts/disableLocalAuth"). + FieldPath string +} + +// PolicyService queries Azure Policy assignments and definitions to detect +// policies that would block deployment of resources with local authentication enabled. +type PolicyService struct { + credentialProvider account.SubscriptionCredentialProvider + armClientOptions *arm.ClientOptions +} + +// NewPolicyService creates a new PolicyService. +func NewPolicyService( + credentialProvider account.SubscriptionCredentialProvider, + armClientOptions *arm.ClientOptions, +) *PolicyService { + return &PolicyService{ + credentialProvider: credentialProvider, + armClientOptions: armClientOptions, + } +} + +// FindLocalAuthDenyPolicies lists policy assignments on the subscription and inspects +// their definitions for deny-effect rules that require disableLocalAuth to be true. +// It returns a list of matching policies with their target resource types. +func (s *PolicyService) FindLocalAuthDenyPolicies( + ctx context.Context, + subscriptionId string, +) ([]LocalAuthDenyPolicy, error) { + credential, err := s.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return nil, fmt.Errorf("getting credential for subscription %s: %w", subscriptionId, err) + } + + assignmentsClient, err := armpolicy.NewAssignmentsClient(subscriptionId, credential, s.armClientOptions) + if err != nil { + return nil, fmt.Errorf("creating policy assignments client: %w", err) + } + + definitionsClient, err := armpolicy.NewDefinitionsClient(subscriptionId, credential, s.armClientOptions) + if err != nil { + return nil, fmt.Errorf("creating policy definitions client: %w", err) + } + + setDefinitionsClient, err := armpolicy.NewSetDefinitionsClient(subscriptionId, credential, s.armClientOptions) + if err != nil { + return nil, fmt.Errorf("creating policy set definitions client: %w", err) + } + + // List all policy assignments for the subscription. + var assignments []*armpolicy.Assignment + pager := assignmentsClient.NewListPager(nil) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("listing policy assignments: %w", err) + } + assignments = append(assignments, page.Value...) + } + log.Printf("policy preflight: found %d policy assignments for subscription %s", len(assignments), subscriptionId) + + var results []LocalAuthDenyPolicy + + // Cache fetched definitions/set definitions to avoid duplicate API calls when + // multiple assignments reference the same definition. + defCache := make(map[string]*armpolicy.Definition) + setDefCache := make(map[string]*armpolicy.SetDefinition) + + for _, assignment := range assignments { + if assignment.Properties == nil || assignment.Properties.PolicyDefinitionID == nil { + continue + } + + defID := *assignment.Properties.PolicyDefinitionID + assignmentName := "" + if assignment.Properties.DisplayName != nil { + assignmentName = *assignment.Properties.DisplayName + } + + // Resolve the assignment's parameter values so we can evaluate parameterized effects. + assignmentParams := extractAssignmentParams(assignment) + + if isBuiltInPolicyDefinition(defID) || isCustomPolicyDefinition(defID) { + // Single policy definition — check it directly. + policies := s.checkPolicyDefinition( + ctx, definitionsClient, defID, assignmentName, assignmentParams, defCache, + ) + results = append(results, policies...) + } else if isPolicySetDefinition(defID) { + // Policy set (initiative) — enumerate its member definitions. + policies := s.checkPolicySetDefinition( + ctx, setDefinitionsClient, definitionsClient, defID, assignmentName, assignmentParams, + defCache, setDefCache, + ) + results = append(results, policies...) + } + } + + return results, nil +} + +// checkPolicyDefinition fetches a single policy definition (using cache) and inspects it for +// disableLocalAuth deny rules. Returns any matching policies found. +func (s *PolicyService) checkPolicyDefinition( + ctx context.Context, + client *armpolicy.DefinitionsClient, + definitionID string, + assignmentName string, + assignmentParams map[string]any, + cache map[string]*armpolicy.Definition, +) []LocalAuthDenyPolicy { + definition, ok := cache[definitionID] + if !ok { + var err error + definition, err = getPolicyDefinitionByID(ctx, client, definitionID) + if err != nil { + log.Printf("policy preflight: could not fetch policy definition %s: %v", definitionID, err) + cache[definitionID] = nil + return nil + } + cache[definitionID] = definition + } + if definition == nil { + return nil + } + + return extractLocalAuthDenyPolicies(definition, assignmentName, assignmentParams) +} + +// checkPolicySetDefinition fetches a policy set definition (initiative) and checks +// each of its member policy definitions for disableLocalAuth deny rules. +func (s *PolicyService) checkPolicySetDefinition( + ctx context.Context, + setClient *armpolicy.SetDefinitionsClient, + defClient *armpolicy.DefinitionsClient, + setDefinitionID string, + assignmentName string, + assignmentParams map[string]any, + defCache map[string]*armpolicy.Definition, + setDefCache map[string]*armpolicy.SetDefinition, +) []LocalAuthDenyPolicy { + setDef, ok := setDefCache[setDefinitionID] + if !ok { + var err error + setDef, err = getPolicySetDefinitionByID(ctx, setClient, setDefinitionID) + if err != nil { + log.Printf( + "policy preflight: could not fetch policy set definition %s: %v", setDefinitionID, err) + setDefCache[setDefinitionID] = nil + return nil + } + setDefCache[setDefinitionID] = setDef + } + if setDef == nil { + return nil + } + + if setDef.Properties == nil || setDef.Properties.PolicyDefinitions == nil { + return nil + } + + var results []LocalAuthDenyPolicy + for _, member := range setDef.Properties.PolicyDefinitions { + if member.PolicyDefinitionID == nil { + continue + } + + // Merge set-level parameters with member-level parameter values. + memberParams := mergeParams(assignmentParams, member.Parameters) + + policies := s.checkPolicyDefinition( + ctx, defClient, *member.PolicyDefinitionID, assignmentName, memberParams, defCache, + ) + results = append(results, policies...) + } + + return results +} + +// getPolicyDefinitionByID fetches a policy definition by its full resource ID. +// It handles built-in, subscription-scoped, and management-group-scoped definitions. +func getPolicyDefinitionByID( + ctx context.Context, + client *armpolicy.DefinitionsClient, + definitionID string, +) (*armpolicy.Definition, error) { + name := lastSegment(definitionID) + if name == "" { + return nil, fmt.Errorf("invalid policy definition ID: %s", definitionID) + } + + if isBuiltInPolicyDefinition(definitionID) { + resp, err := client.GetBuiltIn(ctx, name, nil) + if err != nil { + return nil, err + } + return &resp.Definition, nil + } + + if mgID := extractManagementGroupID(definitionID); mgID != "" { + resp, err := client.GetAtManagementGroup(ctx, mgID, name, nil) + if err != nil { + return nil, err + } + return &resp.Definition, nil + } + + resp, err := client.Get(ctx, name, nil) + if err != nil { + return nil, err + } + return &resp.Definition, nil +} + +// getPolicySetDefinitionByID fetches a policy set definition by its full resource ID. +// It handles built-in, subscription-scoped, and management-group-scoped set definitions. +func getPolicySetDefinitionByID( + ctx context.Context, + client *armpolicy.SetDefinitionsClient, + setDefinitionID string, +) (*armpolicy.SetDefinition, error) { + name := lastSegment(setDefinitionID) + if name == "" { + return nil, fmt.Errorf("invalid policy set definition ID: %s", setDefinitionID) + } + + if isBuiltInPolicySetDefinition(setDefinitionID) { + resp, err := client.GetBuiltIn(ctx, name, nil) + if err != nil { + return nil, err + } + return &resp.SetDefinition, nil + } + + if mgID := extractManagementGroupID(setDefinitionID); mgID != "" { + resp, err := client.GetAtManagementGroup(ctx, mgID, name, nil) + if err != nil { + return nil, err + } + return &resp.SetDefinition, nil + } + + resp, err := client.Get(ctx, name, nil) + if err != nil { + return nil, err + } + return &resp.SetDefinition, nil +} + +// extractLocalAuthDenyPolicies inspects a policy definition for deny-effect rules +// that target disableLocalAuth fields. It returns any matching policies. +func extractLocalAuthDenyPolicies( + def *armpolicy.Definition, + assignmentName string, + assignmentParams map[string]any, +) []LocalAuthDenyPolicy { + if def.Properties == nil || def.Properties.PolicyRule == nil { + return nil + } + + ruleMap, ok := def.Properties.PolicyRule.(map[string]any) + if !ok { + log.Printf("policy preflight: policy rule is not a map for definition %s (type %T)", + stringOrEmpty(def.Name), def.Properties.PolicyRule) + return nil + } + + // Check if the effect is "deny" (either literal or via parameter reference). + if !isDenyEffect(ruleMap, def.Properties.Parameters, assignmentParams) { + return nil + } + + // Parse the "if" condition to find disableLocalAuth field references. + ifBlock, ok := ruleMap["if"] + if !ok { + return nil + } + + results := findLocalAuthConditions(ifBlock, assignmentName) + if len(results) > 0 { + log.Printf("policy preflight: found %d local auth deny condition(s) in policy %q", + len(results), assignmentName) + } + return results +} + +// isDenyEffect checks whether the policy's effect resolves to "deny". +// Effects can be a literal string or a parameter reference like "[parameters('effect')]". +func isDenyEffect( + ruleMap map[string]any, + definitionParams map[string]*armpolicy.ParameterDefinitionsValue, + assignmentParams map[string]any, +) bool { + thenBlock, ok := ruleMap["then"] + if !ok { + return false + } + + thenMap, ok := thenBlock.(map[string]any) + if !ok { + return false + } + + effectVal, ok := thenMap["effect"] + if !ok { + return false + } + + effectStr, ok := effectVal.(string) + if !ok { + log.Printf("policy preflight: effect value is not a string (type %T): %v", effectVal, effectVal) + return false + } + + // Check for literal deny. + if strings.EqualFold(effectStr, "deny") { + return true + } + + // Check for parameter reference: "[parameters('effect')]" or "[parameters('effectName')]". + paramName := extractParameterReference(effectStr) + if paramName == "" { + return false + } + + // First check assignment-level parameters (these override definition defaults). + if v, ok := assignmentParams[paramName]; ok { + if s, ok := v.(string); ok && strings.EqualFold(s, "deny") { + return true + } + return false + } + + // Fall back to the definition's default value. + if definitionParams != nil { + if paramDef, ok := definitionParams[paramName]; ok && paramDef.DefaultValue != nil { + if s, ok := paramDef.DefaultValue.(string); ok && strings.EqualFold(s, "deny") { + return true + } + } + } + + return false +} + +// findLocalAuthConditions traverses the policy rule's "if" block to find conditions +// that reference disableLocalAuth fields and extracts the target resource type. +func findLocalAuthConditions(ifBlock any, assignmentName string) []LocalAuthDenyPolicy { + condMap, ok := ifBlock.(map[string]any) + if !ok { + return nil + } + + // Check for allOf / anyOf compound conditions. + if allOf, ok := condMap["allOf"]; ok { + return findInCompoundCondition(allOf, assignmentName, nil) + } + if anyOf, ok := condMap["anyOf"]; ok { + return findInCompoundCondition(anyOf, assignmentName, nil) + } + + // Single condition — unlikely to be the full pattern but check anyway. + return checkSingleCondition(condMap, assignmentName, nil) +} + +// findInCompoundCondition processes an allOf/anyOf array looking for conditions that +// reference both a resource type and a disableLocalAuth field. +// parentResourceTypes carries any resource types resolved by an ancestor compound condition. +func findInCompoundCondition( + compound any, assignmentName string, parentResourceTypes []string, +) []LocalAuthDenyPolicy { + conditions, ok := compound.([]any) + if !ok { + return nil + } + + // First pass: find resource types from "field: type, equals/in: ..." conditions. + var resourceTypes []string + for _, cond := range conditions { + condMap, ok := cond.(map[string]any) + if !ok { + continue + } + + fieldVal, _ := condMap["field"].(string) + if strings.EqualFold(fieldVal, "type") { + if eq, ok := condMap["equals"].(string); ok { + resourceTypes = append(resourceTypes, eq) + } + if in, ok := condMap["in"].([]any); ok { + for _, item := range in { + if s, ok := item.(string); ok { + resourceTypes = append(resourceTypes, s) + } + } + } + } + } + + // Merge with parent resource types if this level didn't find any. + if len(resourceTypes) == 0 { + resourceTypes = parentResourceTypes + } + + // Second pass: find disableLocalAuth field references. + var results []LocalAuthDenyPolicy + for _, cond := range conditions { + condMap, ok := cond.(map[string]any) + if !ok { + continue + } + results = append(results, checkSingleCondition(condMap, assignmentName, resourceTypes)...) + + // Recurse into nested conditions, passing resolved resource types. + if allOf, ok := condMap["allOf"]; ok { + results = append(results, findInCompoundCondition(allOf, assignmentName, resourceTypes)...) + } + if anyOf, ok := condMap["anyOf"]; ok { + results = append(results, findInCompoundCondition(anyOf, assignmentName, resourceTypes)...) + } + } + + return results +} + +// checkSingleCondition checks if a single condition references a disableLocalAuth field. +// resourceTypes are the candidate resource types resolved from sibling conditions. +func checkSingleCondition( + condMap map[string]any, + assignmentName string, + resourceTypes []string, +) []LocalAuthDenyPolicy { + fieldVal, ok := condMap["field"].(string) + if !ok { + return nil + } + + if !isLocalAuthField(fieldVal) { + return nil + } + + // If we don't have resource types from a sibling condition, try to derive one + // from the field path (e.g. "Microsoft.Storage/storageAccounts/allowSharedKeyAccess"). + if len(resourceTypes) == 0 { + if rt := resourceTypeFromFieldPath(fieldVal); rt != "" { + resourceTypes = []string{rt} + } + } + + if len(resourceTypes) == 0 { + return nil + } + + // Emit one result per resource type. + results := make([]LocalAuthDenyPolicy, 0, len(resourceTypes)) + for _, rt := range resourceTypes { + results = append(results, LocalAuthDenyPolicy{ + PolicyName: assignmentName, + ResourceType: rt, + FieldPath: fieldVal, + }) + } + return results +} + +// isLocalAuthField returns true if the field path references a local authentication property. +func isLocalAuthField(field string) bool { + lower := strings.ToLower(field) + return strings.HasSuffix(lower, "/disablelocalauth") || + strings.HasSuffix(lower, "/allowsharedkeyaccess") || + strings.EqualFold(field, "disableLocalAuth") || + strings.EqualFold(field, "allowSharedKeyAccess") +} + +// resourceTypeFromFieldPath extracts the resource type from a fully qualified field path. +// For example, "Microsoft.CognitiveServices/accounts/disableLocalAuth" returns +// "Microsoft.CognitiveServices/accounts". +func resourceTypeFromFieldPath(field string) string { + idx := strings.LastIndex(field, "/") + if idx <= 0 { + return "" + } + candidate := field[:idx] + // Basic validation: resource types contain at least one "/". + if !strings.Contains(candidate, "/") { + return "" + } + return candidate +} + +// extractParameterReference extracts the parameter name from an ARM template parameter +// reference expression like "[parameters('effect')]". Returns empty if not a parameter reference. +func extractParameterReference(expr string) string { + trimmed := strings.TrimSpace(expr) + lower := strings.ToLower(trimmed) + if !strings.HasPrefix(lower, "[parameters('") || !strings.HasSuffix(lower, "')]") { + return "" + } + // Extract between [parameters(' and ')] + inner := trimmed[len("[parameters('"):] + before, _, ok := strings.Cut(inner, "')]") + if !ok { + return "" + } + return before +} + +// extractAssignmentParams extracts parameter values from a policy assignment into a simple map. +func extractAssignmentParams(assignment *armpolicy.Assignment) map[string]any { + if assignment.Properties == nil || assignment.Properties.Parameters == nil { + return nil + } + + params := make(map[string]any, len(assignment.Properties.Parameters)) + for name, val := range assignment.Properties.Parameters { + if val != nil && val.Value != nil { + params[name] = val.Value + } + } + return params +} + +// mergeParams merges assignment-level parameters with member-level parameter values +// from a policy set definition reference. Member parameters may contain parameter +// references like "[parameters('effect')]" that resolve against the assignment parameters. +func mergeParams(assignmentParams map[string]any, memberParams map[string]*armpolicy.ParameterValuesValue) map[string]any { + if len(memberParams) == 0 { + return assignmentParams + } + + merged := make(map[string]any, len(assignmentParams)+len(memberParams)) + maps.Copy(merged, assignmentParams) + + for name, val := range memberParams { + if val == nil || val.Value == nil { + continue + } + + // Check if the member parameter value is itself a reference to an assignment parameter. + if s, ok := val.Value.(string); ok { + if refName := extractParameterReference(s); refName != "" { + if resolved, ok := assignmentParams[refName]; ok { + merged[name] = resolved + continue + } + } + } + + merged[name] = val.Value + } + + return merged +} + +// isBuiltInPolicyDefinition returns true if the definition ID is a built-in policy. +func isBuiltInPolicyDefinition(id string) bool { + return strings.HasPrefix(strings.ToLower(id), "/providers/microsoft.authorization/policydefinitions/") +} + +// isCustomPolicyDefinition returns true if the definition ID is a subscription-scoped custom policy. +func isCustomPolicyDefinition(id string) bool { + lower := strings.ToLower(id) + return strings.Contains(lower, "/providers/microsoft.authorization/policydefinitions/") && + !isBuiltInPolicyDefinition(id) +} + +// isPolicySetDefinition returns true if the definition ID references a policy set (initiative). +func isPolicySetDefinition(id string) bool { + return strings.Contains(strings.ToLower(id), "/providers/microsoft.authorization/policysetdefinitions/") +} + +// isBuiltInPolicySetDefinition returns true if the set definition ID is a built-in policy set. +func isBuiltInPolicySetDefinition(id string) bool { + return strings.HasPrefix(strings.ToLower(id), "/providers/microsoft.authorization/policysetdefinitions/") +} + +// lastSegment returns the last path segment of a resource ID. +func lastSegment(resourceID string) string { + parts := strings.Split(resourceID, "/") + if len(parts) == 0 { + return "" + } + return parts[len(parts)-1] +} + +// extractManagementGroupID extracts the management group ID from a resource ID like +// "/providers/Microsoft.Management/managementGroups/{mgId}/providers/Microsoft.Authorization/..." +// Returns empty string if the ID is not management-group-scoped. +func extractManagementGroupID(resourceID string) string { + lower := strings.ToLower(resourceID) + const prefix = "/providers/microsoft.management/managementgroups/" + idx := strings.Index(lower, prefix) + if idx < 0 { + return "" + } + rest := resourceID[idx+len(prefix):] + before, _, ok := strings.Cut(rest, "/") + if !ok { + return rest + } + return before +} + +// ResourceHasLocalAuthDisabled checks whether a resource's properties JSON has +// the disableLocalAuth property set to true (or allowSharedKeyAccess set to false +// for storage accounts). +func ResourceHasLocalAuthDisabled(resourceType string, properties json.RawMessage) bool { + if len(properties) == 0 { + return false + } + + var props map[string]any + if err := json.Unmarshal(properties, &props); err != nil { + return false + } + + // Storage accounts use allowSharedKeyAccess (inverted logic). + if strings.EqualFold(resourceType, "Microsoft.Storage/storageAccounts") { + if v, ok := props["allowSharedKeyAccess"]; ok { + if b, ok := v.(bool); ok { + return !b // allowSharedKeyAccess=false means local auth is disabled + } + } + return false + } + + // Most resource types use disableLocalAuth. + if v, ok := props["disableLocalAuth"]; ok { + if b, ok := v.(bool); ok { + return b + } + } + + return false +} + +// stringOrEmpty safely dereferences a string pointer, returning "" if nil. +func stringOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/cli/azd/pkg/azapi/policy_service_test.go b/cli/azd/pkg/azapi/policy_service_test.go new file mode 100644 index 00000000000..df7308f5aa5 --- /dev/null +++ b/cli/azd/pkg/azapi/policy_service_test.go @@ -0,0 +1,523 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azapi + +import ( + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy" + "github.com/stretchr/testify/require" +) + +func TestExtractLocalAuthDenyPolicies_DenyLiteral(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.CognitiveServices/accounts", + }, + map[string]any{ + "field": "Microsoft.CognitiveServices/accounts/disableLocalAuth", + "notEquals": true, + }, + }, + }, + "then": map[string]any{ + "effect": "deny", + }, + }, + }, + } + + results := extractLocalAuthDenyPolicies(def, "test-policy", nil) + require.Len(t, results, 1) + require.Equal(t, "Microsoft.CognitiveServices/accounts", results[0].ResourceType) + require.Equal(t, "test-policy", results[0].PolicyName) + require.Contains(t, results[0].FieldPath, "disableLocalAuth") +} + +func TestExtractLocalAuthDenyPolicies_ParameterizedEffect_Deny(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + Parameters: map[string]*armpolicy.ParameterDefinitionsValue{ + "effect": { + DefaultValue: "Audit", + }, + }, + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.EventHub/namespaces", + }, + map[string]any{ + "field": "Microsoft.EventHub/namespaces/disableLocalAuth", + "notEquals": true, + }, + }, + }, + "then": map[string]any{ + "effect": "[parameters('effect')]", + }, + }, + }, + } + + // With assignment params overriding to Deny. + assignmentParams := map[string]any{"effect": "Deny"} + results := extractLocalAuthDenyPolicies(def, "test-policy", assignmentParams) + require.Len(t, results, 1) + require.Equal(t, "Microsoft.EventHub/namespaces", results[0].ResourceType) +} + +func TestExtractLocalAuthDenyPolicies_ParameterizedEffect_Audit(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + Parameters: map[string]*armpolicy.ParameterDefinitionsValue{ + "effect": { + DefaultValue: "Audit", + }, + }, + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.EventHub/namespaces", + }, + map[string]any{ + "field": "Microsoft.EventHub/namespaces/disableLocalAuth", + "notEquals": true, + }, + }, + }, + "then": map[string]any{ + "effect": "[parameters('effect')]", + }, + }, + }, + } + + // No assignment params — falls back to default "Audit", which is not deny. + results := extractLocalAuthDenyPolicies(def, "test-policy", nil) + require.Empty(t, results) +} + +func TestExtractLocalAuthDenyPolicies_NoLocalAuthField(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.Storage/storageAccounts", + }, + map[string]any{ + "field": "Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly", + "equals": false, + }, + }, + }, + "then": map[string]any{ + "effect": "deny", + }, + }, + }, + } + + results := extractLocalAuthDenyPolicies(def, "test-policy", nil) + require.Empty(t, results) +} + +func TestExtractLocalAuthDenyPolicies_StorageAllowSharedKeyAccess(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.Storage/storageAccounts", + }, + map[string]any{ + "field": "Microsoft.Storage/storageAccounts/allowSharedKeyAccess", + "notEquals": false, + }, + }, + }, + "then": map[string]any{ + "effect": "deny", + }, + }, + }, + } + + results := extractLocalAuthDenyPolicies(def, "test-policy", nil) + require.Len(t, results, 1) + require.Equal(t, "Microsoft.Storage/storageAccounts", results[0].ResourceType) +} + +func TestExtractLocalAuthDenyPolicies_NestedAnyOf(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.ServiceBus/namespaces", + }, + map[string]any{ + "anyOf": []any{ + map[string]any{ + "field": "Microsoft.ServiceBus/namespaces/disableLocalAuth", + "equals": false, + }, + map[string]any{ + "field": "Microsoft.ServiceBus/namespaces/disableLocalAuth", + "exists": false, + }, + }, + }, + }, + }, + "then": map[string]any{ + "effect": "Deny", + }, + }, + }, + } + + results := extractLocalAuthDenyPolicies(def, "test-nested", nil) + require.NotEmpty(t, results) + require.Equal(t, "Microsoft.ServiceBus/namespaces", results[0].ResourceType) +} + +func TestExtractLocalAuthDenyPolicies_MultipleResourceTypesInArray(t *testing.T) { + t.Parallel() + + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "in": []any{ + "Microsoft.EventHub/namespaces", + "Microsoft.ServiceBus/namespaces", + }, + }, + map[string]any{ + "field": "disableLocalAuth", + "notEquals": true, + }, + }, + }, + "then": map[string]any{ + "effect": "Deny", + }, + }, + }, + } + + results := extractLocalAuthDenyPolicies(def, "multi-type-policy", nil) + require.Len(t, results, 2) + + types := make(map[string]bool) + for _, r := range results { + types[r.ResourceType] = true + } + require.True(t, types["Microsoft.EventHub/namespaces"]) + require.True(t, types["Microsoft.ServiceBus/namespaces"]) +} + +func TestExtractLocalAuthDenyPolicies_NestedConditionInheritsResourceType(t *testing.T) { + t.Parallel() + + // The resource type is declared at the outer allOf level, and the disableLocalAuth + // condition is in a nested anyOf. The nested level should inherit the resource type. + def := &armpolicy.Definition{ + Properties: &armpolicy.DefinitionProperties{ + PolicyRule: map[string]any{ + "if": map[string]any{ + "allOf": []any{ + map[string]any{ + "field": "type", + "equals": "Microsoft.Search/searchServices", + }, + map[string]any{ + "anyOf": []any{ + map[string]any{ + "field": "disableLocalAuth", + "equals": false, + }, + }, + }, + }, + }, + "then": map[string]any{ + "effect": "Deny", + }, + }, + }, + } + + results := extractLocalAuthDenyPolicies(def, "nested-inherit", nil) + require.Len(t, results, 1) + require.Equal(t, "Microsoft.Search/searchServices", results[0].ResourceType) +} + +func TestExtractManagementGroupID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + id string + want string + }{ + { + "management group scoped policy definition", + "/providers/Microsoft.Management/managementGroups/myMgGroup" + + "/providers/Microsoft.Authorization/policyDefinitions/abc123", + "myMgGroup", + }, + { + "management group scoped policy set definition", + "/providers/Microsoft.Management/managementGroups/" + + "72f988bf-86f1-41af-91ab-2d7cd011db47" + + "/providers/Microsoft.Authorization/policySetDefinitions/8e7a35ba", + "72f988bf-86f1-41af-91ab-2d7cd011db47", + }, + { + "built-in policy definition", + "/providers/Microsoft.Authorization/policyDefinitions/6300012e-e9a4-4649-b41f-a85f5c43be91", + "", + }, + { + "subscription scoped custom definition", + "/subscriptions/faa080af/providers/Microsoft.Authorization/policyDefinitions/custom123", + "", + }, + { + "empty string", + "", + "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, extractManagementGroupID(tt.id)) + }) + } +} + +func TestIsLocalAuthField(t *testing.T) { + t.Parallel() + + tests := []struct { + field string + want bool + }{ + {"Microsoft.CognitiveServices/accounts/disableLocalAuth", true}, + {"Microsoft.EventHub/namespaces/disableLocalAuth", true}, + {"Microsoft.Storage/storageAccounts/allowSharedKeyAccess", true}, + {"Microsoft.ServiceBus/namespaces/disableLocalAuth", true}, + {"disableLocalAuth", true}, + {"Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly", false}, + {"type", false}, + {"location", false}, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, isLocalAuthField(tt.field)) + }) + } +} + +func TestResourceTypeFromFieldPath(t *testing.T) { + t.Parallel() + + tests := []struct { + field string + want string + }{ + {"Microsoft.CognitiveServices/accounts/disableLocalAuth", "Microsoft.CognitiveServices/accounts"}, + {"Microsoft.EventHub/namespaces/disableLocalAuth", "Microsoft.EventHub/namespaces"}, + {"disableLocalAuth", ""}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, resourceTypeFromFieldPath(tt.field)) + }) + } +} + +func TestExtractParameterReference(t *testing.T) { + t.Parallel() + + tests := []struct { + expr string + want string + }{ + {"[parameters('effect')]", "effect"}, + {"[parameters('myEffect')]", "myEffect"}, + {"deny", ""}, + {"[concat('a', 'b')]", ""}, + {"", ""}, + {" [parameters('effect')] ", "effect"}, + {"\t[parameters('effect')]\n", "effect"}, + } + + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, extractParameterReference(tt.expr)) + }) + } +} + +func TestResourceHasLocalAuthDisabled(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceType string + properties string + want bool + }{ + { + "CognitiveServices disabled", + "Microsoft.CognitiveServices/accounts", + `{"disableLocalAuth": true}`, + true, + }, + { + "CognitiveServices enabled", + "Microsoft.CognitiveServices/accounts", + `{"disableLocalAuth": false}`, + false, + }, + { + "CognitiveServices missing property", + "Microsoft.CognitiveServices/accounts", + `{}`, + false, + }, + { + "Storage allowSharedKeyAccess false", + "Microsoft.Storage/storageAccounts", + `{"allowSharedKeyAccess": false}`, + true, + }, + { + "Storage allowSharedKeyAccess true", + "Microsoft.Storage/storageAccounts", + `{"allowSharedKeyAccess": true}`, + false, + }, + { + "Empty properties", + "Microsoft.EventHub/namespaces", + ``, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, ResourceHasLocalAuthDisabled( + tt.resourceType, []byte(tt.properties), + )) + }) + } +} + +func TestIsDenyEffect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ruleMap map[string]any + definitionParams map[string]*armpolicy.ParameterDefinitionsValue + assignmentParams map[string]any + want bool + }{ + { + "literal deny", + map[string]any{"then": map[string]any{"effect": "deny"}}, + nil, nil, true, + }, + { + "literal Deny (capitalized)", + map[string]any{"then": map[string]any{"effect": "Deny"}}, + nil, nil, true, + }, + { + "literal audit", + map[string]any{"then": map[string]any{"effect": "audit"}}, + nil, nil, false, + }, + { + "parameterized deny via assignment", + map[string]any{"then": map[string]any{"effect": "[parameters('effect')]"}}, + nil, + map[string]any{"effect": "Deny"}, + true, + }, + { + "parameterized deny via default", + map[string]any{"then": map[string]any{"effect": "[parameters('effect')]"}}, + map[string]*armpolicy.ParameterDefinitionsValue{ + "effect": {DefaultValue: "Deny"}, + }, + nil, true, + }, + { + "parameterized audit via default", + map[string]any{"then": map[string]any{"effect": "[parameters('effect')]"}}, + map[string]*armpolicy.ParameterDefinitionsValue{ + "effect": {DefaultValue: "Audit"}, + }, + nil, false, + }, + { + "no then block", + map[string]any{}, + nil, nil, false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tt.want, isDenyEffect(tt.ruleMap, tt.definitionParams, tt.assignmentParams)) + }) + } +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index c205e40c8bc..859659a59b5 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -2156,6 +2156,11 @@ func (p *BicepProvider) validatePreflight( // principal has the required write permission. localPreflight.AddCheck(p.checkRoleAssignmentPermissions) + // Register the local auth policy check. This detects Azure Policy assignments + // that deny resources with local authentication enabled (disableLocalAuth != true) + // and warns if the template contains affected resources. + localPreflight.AddCheck(p.checkLocalAuthPolicy) + results, err := localPreflight.validate(ctx, p.console, armTemplate, armParameters) if err != nil { return false, fmt.Errorf("local preflight validation failed: %w", err) @@ -2255,6 +2260,87 @@ func (p *BicepProvider) checkRoleAssignmentPermissions( return nil, nil } +// checkLocalAuthPolicy is a PreflightCheckFn that detects Azure Policy assignments on the +// subscription that deny resources with local authentication enabled. If any template resources +// have disableLocalAuth unset or false while a deny policy exists for that resource type, +// a warning is returned so the user knows the deployment will be blocked. +func (p *BicepProvider) checkLocalAuthPolicy( + ctx context.Context, valCtx *validationContext, +) (*PreflightCheckResult, error) { + if len(valCtx.SnapshotResources) == 0 { + log.Printf("policy preflight: no snapshot resources, skipping local auth policy check") + return nil, nil + } + + var policyService *azapi.PolicyService + if err := p.serviceLocator.Resolve(&policyService); err != nil { + log.Printf("could not resolve PolicyService, skipping local auth policy check: %v", err) + return nil, nil + } + + subscriptionId := p.env.GetSubscriptionId() + + denyPolicies, err := policyService.FindLocalAuthDenyPolicies(ctx, subscriptionId) + if err != nil { + log.Printf("error checking local auth policies, skipping check: %v", err) + return nil, nil + } + + log.Printf("policy preflight: found %d deny policies targeting local auth", len(denyPolicies)) + + if len(denyPolicies) == 0 { + return nil, nil + } + + // Build a map of resource types to their deny policies (may have multiple per type). + policiesByType := make(map[string][]azapi.LocalAuthDenyPolicy) + for _, policy := range denyPolicies { + key := strings.ToLower(policy.ResourceType) + policiesByType[key] = append(policiesByType[key], policy) + } + + // Log snapshot resource types for diagnostics. + for _, resource := range valCtx.SnapshotResources { + _, matched := policiesByType[strings.ToLower(resource.Type)] + hasLocalAuthDisabled := azapi.ResourceHasLocalAuthDisabled(resource.Type, resource.Properties) + log.Printf("policy preflight: resource %q type=%s policyMatch=%v localAuthDisabled=%v", + resource.Name, resource.Type, matched, hasLocalAuthDisabled) + } + + // Collect affected resource types (deduplicated). + affectedTypes := make(map[string]bool) + for _, resource := range valCtx.SnapshotResources { + _, found := policiesByType[strings.ToLower(resource.Type)] + if !found { + continue + } + + if !azapi.ResourceHasLocalAuthDisabled(resource.Type, resource.Properties) { + affectedTypes[resource.Type] = true + } + } + + if len(affectedTypes) == 0 { + return nil, nil + } + + typeList := slices.Sorted(maps.Keys(affectedTypes)) + var lines []string + for _, t := range typeList { + lines = append(lines, fmt.Sprintf(" - %s", t)) + } + + return &PreflightCheckResult{ + Severity: PreflightCheckWarning, + Message: fmt.Sprintf( + "an Azure Policy on this subscription denies resources with local authentication enabled. "+ + "The following resource types in this deployment may be blocked:\n%s\n"+ + "Disable local authentication on these resources or request a policy exemption.", + strings.Join(lines, "\n"), + ), + }, nil +} + // Deploys the specified Bicep module and parameters with the selected provisioning scope (subscription vs resource group) func (p *BicepProvider) deployModule( ctx context.Context,