diff --git a/internal/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index ef311288dbfe..ca6117a0cb24 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -676,6 +676,15 @@ func (p *MockProvider) PlanResourceChange(r providers.PlanResourceChangeRequest) return resp } + // this resource is deferred + if r.ProposedNewState.Type().HasAttribute("defer") { + if shouldBeDeferred := r.ProposedNewState.GetAttr("defer"); !shouldBeDeferred.IsKnown() || (!shouldBeDeferred.IsNull() && shouldBeDeferred.True()) { + resp.Deferred = &providers.Deferred{ + Reason: providers.DeferredReasonResourceConfigUnknown, + } + } + } + schema, ok := p.getProviderSchema().ResourceTypes[r.TypeName] if !ok { resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("no schema found for %q", r.TypeName)) diff --git a/internal/terraform/context_apply.go b/internal/terraform/context_apply.go index 98432c21b736..8c812a41e670 100644 --- a/internal/terraform/context_apply.go +++ b/internal/terraform/context_apply.go @@ -12,8 +12,10 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/collections" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" @@ -46,6 +48,14 @@ type ApplyOpts struct { // or test runtimes, where the root modules as Terraform sees them aren't // the actual root modules. AllowRootEphemeralOutputs bool + + // Locks is a read-only snapshot of provider locks (from the dependency lock + // file). + Locks map[addrs.Provider]*depsfile.ProviderLock + + // Optional policy client to enable live policy evaluations. + PolicyClient policy.Client + PolicyResults *plans.PolicyResults } // ApplyOpts creates an [ApplyOpts] with copies of all of the elements that @@ -61,6 +71,7 @@ func (po *PlanOpts) ApplyOpts() *ApplyOpts { return &ApplyOpts{ ExternalProviders: po.ExternalProviders, AllowRootEphemeralOutputs: po.AllowRootEphemeralOutputs, + Locks: po.Locks, } } @@ -207,6 +218,10 @@ func (c *Context) ApplyAndEval(plan *plans.Plan, config *configs.Config, opts *A PlanTimeTimestamp: plan.Timestamp, FunctionResults: lang.NewFunctionResultsTable(plan.FunctionResults), + + Locks: opts.Locks, + PolicyClient: opts.PolicyClient, + PolicyResults: opts.PolicyResults, }) diags = diags.Append(walker.NonFatalDiagnostics) diags = diags.Append(walkDiags) @@ -378,6 +393,7 @@ func (c *Context) applyGraph(plan *plans.Plan, config *configs.Config, opts *App Overrides: plan.Overrides, SkipGraphValidation: c.graphOpts.SkipGraphValidation, AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, + PolicyClient: opts.PolicyClient, }).Build(addrs.RootModuleInstance) diags = diags.Append(moreDiags) if moreDiags.HasErrors() { diff --git a/internal/terraform/context_apply_policy_test.go b/internal/terraform/context_apply_policy_test.go new file mode 100644 index 000000000000..c5b9ac88ee62 --- /dev/null +++ b/internal/terraform/context_apply_policy_test.go @@ -0,0 +1,528 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/proto" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestContext2Apply_PolicyEvaluation_Full(t *testing.T) { + mainConfig := ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "foo" + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + + module "child" { + source = "./child" + // this is a computed value in the parent, so will not be available until apply. + input = test_resource.test.id + } + + ` + childConfig := ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + } + + resource "test_instance" "child" { + value = var.input + } + + ` + policyConfig := ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = attrs.sensitive_value == "foo" + } + } + ` + configFiles := map[string]string{ + "main.tf": mainConfig, + "child/child.tf": childConfig, + "main.tfpolicy.hcl": policyConfig, + } + + mod := testModuleInline(t, configFiles) + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + + provider.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + cfg := req.Config.AsValueMap() + if req.TypeName == "test_resource" { + cfg["id"] = cty.StringVal("parent") + } + resp.NewState = cty.ObjectVal(cfg) + return resp + } + state := states.NewState() + + // mock the policy expectations during plan + planPolicyClient := policy.NewTestMockClient(t) + + // The expected values to be sent for policy evaluation. + expected := map[string]cty.Value{ + "test_resource": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NullVal(cty.String), + "sensitive_value": cty.StringVal("foo"), + }), + "test_instance": cty.ObjectVal(map[string]cty.Value{ + "value": cty.UnknownVal(cty.String), + "sensitive_value": cty.NilVal, + }), + } + + planPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + var actual cty.Value + attrs := req.Attrs + target := req.Target + if !attrs.IsNull() { + mp := attrs.AsValueMap() + actual = cty.ObjectVal(map[string]cty.Value{ + "value": mp["value"], + "sensitive_value": mp["sensitive_value"], + }) + } + + if diff := cmp.Diff(actual, expected[target], cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("Unexpected diff (-got +want):\n%s", diff) + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + Parallelism: 1, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(mod, state, &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: planPolicyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + // mock the policy expectations during apply + applyPolicyClient := policy.NewTestMockClient(t) + + // The expected values to be sent for policy evaluation. + expected = map[string]cty.Value{ + "test_resource": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NullVal(cty.String), + "sensitive_value": cty.StringVal("foo"), + }), + "test_instance": cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("parent"), // was unknown in the plan + "sensitive_value": cty.NilVal, + }), + } + applyPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + var actual cty.Value + attrs := req.Attrs + target := req.Target + if !attrs.IsNull() { + mp := attrs.AsValueMap() + actual = cty.ObjectVal(map[string]cty.Value{ + "value": mp["value"], + "sensitive_value": mp["sensitive_value"], + }) + } + + if diff := cmp.Diff(actual, expected[target], cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("Unexpected diff (-got +want):\n%s", diff) + } + + // this return does not actually do anything + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + _, diags = ctx.Apply(plan, mod, &ApplyOpts{ + PolicyClient: applyPolicyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + +} + +// TestContext2Apply_PolicyEvaluationError tests that the apply operation returns policy diagnostics +// when the policy evaluation returns an error. +func TestContext2Apply_PolicyEvaluationError(t *testing.T) { + mainConfig := ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "foo" + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + + module "child" { + source = "./child" + // this is a computed value in the parent, so will not be available until apply. + input = test_resource.test.id + } + + ` + childConfig := ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + } + + resource "test_instance" "child" { + value = var.input + } + + ` + policyConfig := ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = attrs.sensitive_value == "foo" + } + } + ` + configFiles := map[string]string{ + "main.tf": mainConfig, + "child/child.tf": childConfig, + "main.tfpolicy.hcl": policyConfig, + } + + mod := testModuleInline(t, configFiles) + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + + provider.ApplyResourceChangeFn = func(req providers.ApplyResourceChangeRequest) (resp providers.ApplyResourceChangeResponse) { + cfg := req.Config.AsValueMap() + if req.TypeName == "test_resource" { + cfg["id"] = cty.StringVal("parent") + } + resp.NewState = cty.ObjectVal(cfg) + return resp + } + state := states.NewState() + + // mock the policy expectations during plan + planPolicyClient := policy.NewTestMockClient(t) + + // The expected values to be sent for policy evaluation. + expected := map[string]cty.Value{ + "test_resource": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NullVal(cty.String), + "sensitive_value": cty.StringVal("foo"), + }), + "test_instance": cty.ObjectVal(map[string]cty.Value{ + "value": cty.UnknownVal(cty.String), + "sensitive_value": cty.NilVal, + }), + } + + planPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + var actual cty.Value + attrs := req.Attrs + target := req.Target + if !attrs.IsNull() { + mp := attrs.AsValueMap() + actual = cty.ObjectVal(map[string]cty.Value{ + "value": mp["value"], + "sensitive_value": mp["sensitive_value"], + }) + } + + if diff := cmp.Diff(actual, expected[target], cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("Unexpected diff (-got +want):\n%s", diff) + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + Parallelism: 1, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(mod, state, &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: planPolicyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + // mock the policy expectations during apply + applyPolicyClient := policy.NewTestMockClient(t) + + // The expected values to be sent for policy evaluation. + expected = map[string]cty.Value{ + "test_resource": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NullVal(cty.String), + "sensitive_value": cty.StringVal("foo"), + }), + "test_instance": cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("parent"), // was unknown in the plan + "sensitive_value": cty.NilVal, + }), + } + + // Track which resource we're evaluating for different responses + applyPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + var actual cty.Value + attrs := req.Attrs + target := req.Target + if !attrs.IsNull() { + mp := attrs.AsValueMap() + actual = cty.ObjectVal(map[string]cty.Value{ + "value": mp["value"], + "sensitive_value": mp["sensitive_value"], + }) + } + + if diff := cmp.Diff(actual, expected[target], cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("Unexpected diff (-got +want):\n%s", diff) + } + + if target == "test_resource" { + return policy.EvaluationResponse{ + Overall: policy.DenyResult, + Diagnostics: policy.DiagsFromProto([]*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "error message", + }, + }, nil), + } + } + + // test_instance should still be evaluated despite the error in test_resource + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + applyResults := plans.NewPolicyResults() + state, diags = ctx.Apply(plan, mod, &ApplyOpts{ + PolicyClient: applyPolicyClient, + PolicyResults: applyResults, + }) + tfdiags.AssertDiagnosticCount(t, diags, 0) + + var policyDiags tfdiags.Diagnostics + for _, res := range applyResults.Iter() { + policyDiags = policyDiags.Append(res.EvaluationResponse.Diagnostics.AsTerraformDiags()) + } + var exp tfdiags.Diagnostics + exp = exp.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "error message", + Subject: policyDiags[0].Source().Subject.ToHCL().Ptr(), + Extra: policyDiags[0].ExtraInfo(), + }) + tfdiags.AssertDiagnosticsMatch(t, policyDiags, exp) + + addrs := state.AllManagedResourceInstanceObjectAddrs() + if len(addrs) != 2 { + t.Fatalf("expected 1 managed resource in the state, got %d", len(addrs)) + } + + rs := state.Resource(mustAbsResourceAddr("test_resource.test")) + if rs == nil { + t.Fatal("expected resource to be in the state") + } +} + +func TestContext2Apply_PolicyEvaluation_Destroy(t *testing.T) { + mainConfig := ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + ` + policyConfig := ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + ` + configFiles := map[string]string{ + "main.tf": mainConfig, + "main.tfpolicy.hcl": policyConfig, + } + + mod := testModuleInline(t, configFiles) + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + + // Build a pre-existing state with the resource already created. + state := states.BuildState(func(ss *states.SyncState) { + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.test"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bar","sensitive_value":"foo"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }) + + planPolicyClient := policy.NewTestMockClient(t) + var planEvalCalled int + + planPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + planEvalCalled++ + + if req.Target != "test_resource" { + t.Errorf("Plan: expected target to be test_resource, got %s", req.Target) + } + + // For a destroy plan, attrs (the "after" value) should be null. + if !req.Attrs.IsNull() { + t.Errorf("Plan: expected null attrs for destroy evaluation, got %#v", req.Attrs) + } + + // PriorAttrs should contain the state being destroyed. + if req.PriorAttrs.IsNull() { + t.Errorf("Plan: expected non-null PriorAttrs for destroy evaluation") + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + Parallelism: 1, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(mod, state, &PlanOpts{ + Mode: plans.DestroyMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: planPolicyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + if planEvalCalled != 1 { + t.Fatalf("Plan: expected policy Evaluate to be called 1 time, got %d", planEvalCalled) + } + + // Verify the plan contains a delete action. + var foundDelete bool + for _, rc := range plan.Changes.Resources { + if rc.Addr.String() == "test_resource.test" { + if rc.Action != plans.Delete { + t.Errorf("Expected delete action for test_resource.test, got %s", rc.Action) + } + foundDelete = true + } + } + if !foundDelete { + t.Fatal("Expected test_resource.test in plan changes") + } + + // --- Apply phase --- + applyPolicyClient := policy.NewTestMockClient(t) + var applyEvalCalled int + + applyPolicyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + applyEvalCalled++ + + if req.Target != "test_resource" { + t.Errorf("Apply: expected target to be test_resource, got %s", req.Target) + } + + if !req.Attrs.IsNull() { + t.Errorf("Apply: expected null attrs for destroy evaluation, got %#v", req.Attrs) + } + + if req.PriorAttrs.IsNull() { + t.Errorf("Apply: expected non-null PriorAttrs for destroy evaluation") + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + resultState, diags := ctx.Apply(plan, mod, &ApplyOpts{ + PolicyClient: applyPolicyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + if applyEvalCalled != 1 { + t.Fatalf("Apply: expected policy Evaluate to be called 1 time, got %d", applyEvalCalled) + } + + // After a successful destroy, the resource should no longer be in state. + remainingAddrs := resultState.AllManagedResourceInstanceObjectAddrs() + if len(remainingAddrs) != 0 { + t.Fatalf("expected 0 managed resources in the state after destroy, got %d: %v", len(remainingAddrs), remainingAddrs) + } + + rs := resultState.Resource(mustAbsResourceAddr("test_resource.test")) + if rs != nil { + t.Fatal("expected test_resource.test to be removed from state after destroy") + } +} diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index 2c99c9f600e2..c74e02967bfd 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -16,11 +16,13 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/collections" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/lang/globalref" "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/states" @@ -160,6 +162,14 @@ type PlanOpts struct { // or test runtimes, where the root modules as Terraform sees them aren't // the actual root modules. AllowRootEphemeralOutputs bool + + // Locks is a read-only snapshot of provider locks (from the dependency lock + // file). + Locks map[addrs.Provider]*depsfile.ProviderLock + + // Optional policy client to enable live policy evaluations. + PolicyClient policy.Client + PolicyResults policy.EvaluationResponse } // Plan generates an execution plan by comparing the given configuration @@ -790,6 +800,9 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o // Hold reference to this so we can store the table data in the plan file. funcResults := lang.NewFunctionResultsTable(nil) + // Initialize the map to store policy evaluation results. + policyResults := plans.NewPolicyResults() + walker, walkDiags := c.walk(graph, walkOp, &graphWalkOpts{ Config: config, InputState: prevRunState, @@ -802,6 +815,9 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o PlanTimeTimestamp: timestamp, FunctionResults: funcResults, Forget: opts.Forget, + Locks: opts.Locks, + PolicyClient: opts.PolicyClient, + PolicyResults: policyResults, }) diags = diags.Append(walker.NonFatalDiagnostics) diags = diags.Append(walkDiags) @@ -880,10 +896,13 @@ func (c *Context) planWalk(config *configs.Config, prevRunState *states.State, o Checks: states.NewCheckResults(walker.Checks), Timestamp: timestamp, FunctionResults: funcResults.GetHashes(), - // Other fields get populated by Context.Plan after we return } + if policyResults != nil { + plan.PolicyResults = policyResults + } + if !schemaDiags.HasErrors() { deferredResources, deferredDiags := c.deferredResources(schemas, walker.Deferrals.GetDeferredChanges()) diags = diags.Append(deferredDiags) @@ -1019,6 +1038,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, queryPlan: opts.Query, overridePreventDestroy: opts.OverridePreventDestroy, AllowRootEphemeralOutputs: opts.AllowRootEphemeralOutputs, + PolicyClient: opts.PolicyClient, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.RefreshOnlyMode: diff --git a/internal/terraform/context_plan_policy_test.go b/internal/terraform/context_plan_policy_test.go new file mode 100644 index 000000000000..b0355e34acad --- /dev/null +++ b/internal/terraform/context_plan_policy_test.go @@ -0,0 +1,1077 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "context" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/proto" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestContext2Plan_PolicyEvaluation(t *testing.T) { + type data struct { + config *configs.Config + plan *plans.Plan + state *states.State + diags tfdiags.Diagnostics + policy *policy.MockClient + } + cases := []struct { + name string + mainConfig string + childConfig string + policyConfig string + state *states.State + planMode plans.Mode + forceReplace []addrs.AbsResourceInstance + deferralAllowed bool + prepareExpectations func(*testing.T, *data) + assertPolicyResults func(*testing.T, *data) + }{ + { + name: "make policy evaluation calls", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "foo" + } + + variable "input2" { + type = string + default = "bar" + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + + module "child" { + source = "./child" + } + + `, + childConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "child-foo" + } + + resource "test_instance" "test" { + value = "foo" + } + + `, + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = attrs.sensitive_value == "foo" + } + } + `, + prepareExpectations: func(t *testing.T, data *data) { + + // The expected values to be sent for policy evaluation. + expected := map[string]cty.Value{ + "test_resource": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NullVal(cty.String), + "sensitive_value": cty.StringVal("foo"), + }), + + "test_instance": cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("foo"), + }), + } + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + var actual cty.Value + if !req.Attrs.IsNull() { + mp := req.Attrs.AsValueMap() + retMP := map[string]cty.Value{ + "value": mp["value"], + } + if sv, ok := mp["sensitive_value"]; ok { + retMP["sensitive_value"] = sv + } + actual = cty.ObjectVal(retMP) + } + + if diff := cmp.Diff(actual, expected[req.Target], cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("Unexpected diff (-got +want):\n%s", diff) + } + + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: req.Target, + ProviderType: "test", + Operation: proto.Operation_CREATE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + // Both resources are being created, so PriorAttrs should be null. + if !req.PriorAttrs.IsNull() { + t.Errorf("Expected null PriorAttrs for newly created %s, got non-null", req.Target) + return policy.EvaluationResponse{} + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + data.policy.EvaluateModuleFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ModuleMetadata]) policy.EvaluationResponse { + if req.Meta != nil { + if req.Meta.Address != "module.child" { + t.Errorf(`Expected module address to be "module.child", got "%s"`, req.Meta.Address) + } + if req.Meta.Source != "./child" { + t.Errorf(`Expected module source to be "./child", got "%s"`, req.Meta.Source) + } + } + + if req.Target != "./child" { + t.Errorf(`Expected target to be "./child", got %s`, req.Target) + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + }, + assertPolicyResults: func(t *testing.T, d *data) { + if !d.policy.EvaluateProviderCalled { + t.Error("Expected policyClient.EvaluateProvider to be called") + } + if !d.policy.EvaluateModuleCalled { + t.Error("Expected policyClient.EvaluateModule to be called") + } + if !d.policy.EvaluateCalled { + t.Error("Expected policyClient.Evaluate to be called") + } + tfdiags.AssertNoDiagnostics(t, d.diags) + }, + }, + + { + name: "deferred resource: policy is skipped", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "foo" + } + + variable "input2" { + type = string + default = "bar" + } + + resource "test_resource" "test" { + sensitive_value = "foo" + defer = true + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = attrs.sensitive_value == "foo" + } + } + `, + deferralAllowed: true, + prepareExpectations: func(t *testing.T, data *data) { + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + t.Fatalf("Expected policy evaluation to be skipped for deferred resource, but got request for %s", req.Target) + return policy.EvaluationResponse{} + } + }, + assertPolicyResults: func(t *testing.T, d *data) { + if d.policy.EvaluateCalled { + t.Error("Expected policyClient.Evaluate not to be called for deferred resource") + } + tfdiags.AssertNoDiagnostics(t, d.diags) + + if len(d.plan.DeferredResources) != 1 { + t.Fatalf("Expected 1 deferred resource, got %d", len(d.plan.DeferredResources)) + } + }, + }, + { + name: "orphaned resource instance: policy is evaluated", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "foo" + } + + variable "input2" { + type = string + default = "bar" + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = attrs.sensitive_value == "foo" + } + } + `, + state: states.BuildState(func(ss *states.SyncState) { + testAddr := mustResourceInstanceAddr("test_resource.test") + orphanAddr := mustResourceInstanceAddr("test_instance.child") + ss.SetResourceInstanceCurrent( + testAddr, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bin","type":"test_resource","sensitive_value":"foo"}`), + Dependencies: []addrs.ConfigResource{ + orphanAddr.ContainingResource().Config(), + }, + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + ss.SetResourceInstanceCurrent( + orphanAddr, + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bin","type":"test_instance","sensitive_value":"foo-child"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }), + prepareExpectations: func(t *testing.T, data *data) { + // The expected values to be sent for policy evaluation. + expected := map[string]cty.Value{ + "test_resource": cty.ObjectVal(map[string]cty.Value{ + "value": cty.NullVal(cty.String), + "sensitive_value": cty.StringVal("foo"), + }), + + // orphaned resource, so a nil set would be sent for policy evaluation. + "test_instance": cty.NilVal, + } + + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + var actual cty.Value + if !req.Attrs.IsNull() { + mp := req.Attrs.AsValueMap() + actual = cty.ObjectVal(map[string]cty.Value{ + "value": mp["value"], + "sensitive_value": mp["sensitive_value"], + }) + } + + if diff := cmp.Diff(actual, expected[req.Target], cmp.Comparer(cty.Value.RawEquals)); diff != "" { + t.Errorf("Unexpected diff (-got +want):\n%s", diff) + } + + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: req.Target, + ProviderType: "test", + Operation: proto.Operation_DELETE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + // Both resources have prior state, so PriorAttrs should be non-null. + if req.PriorAttrs.IsNull() { + t.Errorf("Expected non-null PriorAttrs for %s, got null", req.Target) + return policy.EvaluationResponse{} + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + }, + }, + { + name: "parent resource policy succeeds, child module resource policy fails", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + variable "input" { + type = string + default = "foo" + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + + module "child" { + source = "./child" + } + `, + childConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_instance" "test" { + value = "forbidden_value" + } + `, + policyConfig: ` + resource_policy "test_resource" "parent_policy" { + enforce { + condition = attrs.sensitive_value == "foo" + } + } + + resource_policy "test_instance" "child_policy" { + enforce { + condition = attrs.value != "forbidden_value" + } + } + `, + prepareExpectations: func(t *testing.T, data *data) { + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: req.Target, + ProviderType: "test", + Operation: proto.Operation_CREATE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + if !req.PriorAttrs.IsNull() { + t.Errorf("Expected null PriorAttrs for newly created %s, got non-null", req.Target) + return policy.EvaluationResponse{} + } + + // Child module resource policy fails + if req.Target == "test_instance" { + return policy.EvaluationResponse{ + Overall: policy.DenyResult, + Enforcements: []policy.EnforcementResult{}, + Diagnostics: policy.DiagsFromProto([]*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "Child module policy violation", + Detail: "Resource test_instance.test violates policy: forbidden value detected", + Result: &proto.DiagnosticResult{ + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + }, + Subject: &proto.Range{ + Filename: "child_policy.tfpolicy.hcl", + Start: &proto.Position{ + Line: 1, + Column: 1, + }, + End: &proto.Position{ + Line: 4, + Column: 10, + }, + }, + }, + }, nil), + } + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + }, + assertPolicyResults: func(t *testing.T, data *data) { + tfdiags.AssertDiagnosticCount(t, data.diags, 1) + var exp tfdiags.Diagnostics + // We want to test that the diagnostic subject is set to the terraform file, + // with an internal extra data for the policy file. + // This allows us to display both source information in the diagnostic. + policyClientDiag := data.diags[0] + policyExtra, ok := data.diags[0].ExtraInfo().(*policy.PolicyExtra) + if !ok { + t.Fatalf("Expected diagnostic extra info to be a *policy.PolicyExtra, got %T", policyClientDiag.ExtraInfo()) + } + tfSubject := policyClientDiag.Source().Subject.ToHCL().Ptr() + if filepath.Ext(tfSubject.Filename) != ".tf" { + t.Fatalf("Expected diagnostic subject filename to end with .tf, got %q", tfSubject.Filename) + } + if !strings.HasSuffix(policyExtra.Range.Subject.Filename, ".tfpolicy.hcl") { + t.Fatalf("Expected policy diagnostic subject filename to end with .tfpolicy.hcl, got %q", policyExtra.Range.Subject.Filename) + } + + exp = exp.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Child module policy violation", + Detail: "Resource test_instance.test violates policy: forbidden value detected", + Subject: tfSubject, + }) + tfdiags.AssertDiagnosticsMatch(t, data.diags, exp) + + // Check that parent resource was planned successfully but child resource was not + resourceChanges := data.plan.Changes.Resources + var parentFound, childFound bool + for _, change := range resourceChanges { + if change.Addr.String() == "test_resource.test" { + parentFound = true + } + if change.Addr.String() == "module.child.test_instance.test" { + childFound = true + } + } + + if !parentFound { + t.Error("Expected parent resource test_resource.test to be planned") + } + if !childFound { + t.Error("Expected child resource module.child.test_instance.test to be planned due to policy failure") + } + }, + }, + { + name: "destroy plan: policy is evaluated with null attrs", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "test" { + sensitive_value = "foo" + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + `, + planMode: plans.DestroyMode, + state: states.BuildState(func(ss *states.SyncState) { + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.test"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bar","type":"test_resource","sensitive_value":"foo"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }), + prepareExpectations: func(t *testing.T, data *data) { + // EvalPolicy should be called during the actual destroy plan with null attrs + var called int + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + called++ + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: "test_resource", + ProviderType: "test", + Operation: proto.Operation_DELETE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + if !req.Attrs.IsNull() { + t.Errorf("Expected null attrs for destroy evaluation") + } + + if req.PriorAttrs.IsNull() { + t.Errorf("Expected non-null PriorAttrs for destroy evaluation") + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + t.Cleanup(func() { + if called != 1 { + t.Errorf("Expected EvalPolicy to be called once got %d", called) + } + }) + }, + assertPolicyResults: func(t *testing.T, d *data) { + if !d.policy.EvaluateCalled { + t.Error("Expected policyClient.Evaluate to be called for destroy plan") + } + tfdiags.AssertNoDiagnostics(t, d.diags) + + // Verify the plan contains a delete action + for _, rc := range d.plan.Changes.Resources { + if rc.Addr.String() == "test_resource.test" { + if rc.Action != plans.Delete { + t.Errorf("Expected delete action for test_resource.test, got %s", rc.Action) + } + return + } + } + t.Error("Expected test_resource.test in plan changes") + }, + }, + { + name: "destroy plan: policy denies destruction", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "test" { + sensitive_value = "secret" + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "no_destroy" { + enforce { + condition = false + } + } + `, + planMode: plans.DestroyMode, + state: states.BuildState(func(ss *states.SyncState) { + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.test"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bar","type":"test_resource","sensitive_value":"secret"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }), + prepareExpectations: func(t *testing.T, data *data) { + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + if diff := cmp.Diff(req.Meta, proto.ResourceMetadata{ + Type: "test_resource", + ProviderType: "test", + Operation: proto.Operation_DELETE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + if req.PriorAttrs.IsNull() { + t.Errorf("Expected non-null PriorAttrs for destroy evaluation") + return policy.EvaluationResponse{} + } + + return policy.EvaluationResponse{ + Overall: policy.DenyResult, + Enforcements: []policy.EnforcementResult{}, + Diagnostics: policy.DiagsFromProto([]*proto.Diagnostic{ + { + Severity: proto.Severity_ERROR, + Summary: "Destruction not allowed", + Detail: "Policy prevents destruction of test_resource.test", + Result: &proto.DiagnosticResult{ + Result: proto.EvaluateResult_DENY_EVALUATE_RESULT, + }, + Subject: &proto.Range{ + Filename: "no_destroy.tfpolicy.hcl", + Start: &proto.Position{ + Line: 1, + Column: 1, + }, + End: &proto.Position{ + Line: 4, + Column: 10, + }, + }, + }, + }, nil), + } + } + }, + assertPolicyResults: func(t *testing.T, d *data) { + if !d.policy.EvaluateCalled { + t.Error("Expected policyClient.Evaluate to be called for destroy plan") + } + tfdiags.AssertDiagnosticCount(t, d.diags, 1) + + var exp tfdiags.Diagnostics + policyClientDiag := d.diags[0] + tfSubject := policyClientDiag.Source().Subject.ToHCL().Ptr() + exp = exp.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Destruction not allowed", + Detail: "Policy prevents destruction of test_resource.test", + Subject: tfSubject, + }) + tfdiags.AssertDiagnosticsMatch(t, d.diags, exp) + }, + }, + { + name: "create resource with cbd. policy is evaluated with create operation", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "test" { + sensitive_value = "after" + + lifecycle { + create_before_destroy = true + } + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + `, + state: states.NewState(), + prepareExpectations: func(t *testing.T, data *data) { + var called int + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + called++ + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: "test_resource", + ProviderType: "test", + Operation: proto.Operation_CREATE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + t.Cleanup(func() { + if called != 1 { + t.Errorf("Expected EvalPolicy to be called once got %d", called) + } + }) + }, + }, + { + name: "update resource with cbd. policy is evaluated with update operation", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "test" { + sensitive_value = "after" + + lifecycle { + create_before_destroy = true + } + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + `, + state: states.BuildState(func(ss *states.SyncState) { + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.test"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bar","type":"test_resource","sensitive_value":"secret"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }), + prepareExpectations: func(t *testing.T, data *data) { + var called int + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + called++ + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: "test_resource", + ProviderType: "test", + Operation: proto.Operation_UPDATE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + t.Cleanup(func() { + if called != 1 { + t.Errorf("Expected EvalPolicy to be called once got %d", called) + } + }) + }, + }, + { + name: "replace resource with cbd. policy is evaluated with update operation", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "test" { + sensitive_value = "after" + + lifecycle { + create_before_destroy = true + } + } + `, + childConfig: "", + policyConfig: ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + `, + state: states.BuildState(func(ss *states.SyncState) { + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.test"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"bar","type":"test_resource","sensitive_value":"secret"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }), + forceReplace: []addrs.AbsResourceInstance{mustResourceInstanceAddr("test_resource.test")}, + prepareExpectations: func(t *testing.T, data *data) { + var called int + data.policy.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + called++ + if diff := cmp.Diff(req.Meta, &proto.ResourceMetadata{ + Type: "test_resource", + ProviderType: "test", + Operation: proto.Operation_UPDATE, + }, protocmp.Transform()); diff != "" { + t.Errorf("Invalid resource metadata: %s", diff) + } + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + t.Cleanup(func() { + if called != 1 { + t.Errorf("Expected EvalPolicy to be called once got %d", called) + } + }) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + configFiles := map[string]string{"main.tf": tc.mainConfig} + if tc.childConfig != "" { + configFiles["child/child.tf"] = tc.childConfig + } + if tc.policyConfig != "" { + configFiles["main.tfpolicy.hcl"] = tc.policyConfig + } + + mod := testModuleInline(t, configFiles) + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + state := states.NewState() + if tc.state != nil { + state = tc.state + } + + // mock expectations + policyClient := policy.NewTestMockClient(t) + data := &data{ + config: mod, + state: state, + policy: policyClient, + } + planMode := tc.planMode + if planMode == 0 { + planMode = plans.NormalMode + } + + if tc.prepareExpectations != nil { + tc.prepareExpectations(t, data) + } + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + Parallelism: 1, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(mod, state, &PlanOpts{ + Mode: planMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: policyClient, + DeferralAllowed: tc.deferralAllowed, + ForceReplace: tc.forceReplace, + }) + // The plan itself should not have diagnostics. Policy diagnostics are propagated via + // the PolicyResults object. + tfdiags.AssertNoDiagnostics(t, diags) + + data.plan = plan + for _, result := range plan.PolicyResults.Iter() { + data.diags = data.diags.Append(result.EvaluationResponse.Diagnostics.AsTerraformDiags()) + } + if tc.assertPolicyResults != nil { + tc.assertPolicyResults(t, data) + } else { + tfdiags.AssertNoDiagnostics(t, data.diags) + } + }) + } +} + +func TestContext2Plan_PolicyCallback(t *testing.T) { + // This test verifies that the GetResources callback provided during policy + // evaluation works correctly: matching all resources, filtering by + // attributes, returning nothing for non-matching filters, and returning + // nothing for non-existent resource types. + mainConfig := ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_instance" "foo" { + ami = "bar" + } + + resource "test_instance" "baz" { + ami = "qux" + depends_on = [test_instance.foo] + } + + resource "test_instance" "boop" { + ami = "booper" + depends_on = [test_instance.baz] + } + ` + + policyConfig := ` + resource_policy "test_instance" "policy_name" { + enforce { + condition = core::getresources("some_resource_type", {})[0].value != null + } + } + ` + + mod := testModuleInline(t, map[string]string{ + "main.tf": mainConfig, + "main.tfpolicy.hcl": policyConfig, + }) + + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + provider.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{ + PlannedState: req.ProposedNewState, + } + } + + policyClient := policy.NewTestMockClient(t) + + type callbackResult struct { + matchAllCount int + matchAllResults []cty.Value + filteredCount int + filteredResults []cty.Value + noMatchCount int + unknownTypeCount int + } + + var mu sync.Mutex + results := make(map[string]callbackResult) + + policyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.ResourceMetadata]) policy.EvaluationResponse { + cr := callbackResult{} + + if req.Callbacks.GetResources == nil { + t.Errorf("GetResources callback was nil") + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + // 1. Match all test_instance resources with null attrs (no filter). + all, err := req.Callbacks.GetResources("test_instance", cty.NullVal(cty.DynamicPseudoType)) + if err != nil { + t.Errorf("GetResources(test_instance, null): %v", err) + } else { + cr.matchAllCount = len(all) + cr.matchAllResults = all + } + + // 2. Match resources with ami="bar" filter. + filtered, err := req.Callbacks.GetResources("test_instance", cty.ObjectVal(map[string]cty.Value{ + "ami": cty.StringVal("bar"), + })) + if err != nil { + t.Errorf("GetResources(test_instance, ami=bar): %v", err) + } else { + cr.filteredCount = len(filtered) + cr.filteredResults = filtered + } + + // 3. Match with an attribute filter that will never match any planned resource. + noMatch, err := req.Callbacks.GetResources("test_instance", cty.ObjectVal(map[string]cty.Value{ + "ami": cty.StringVal("nonexistent"), + })) + if err != nil { + t.Errorf("GetResources(test_instance, ami=nonexistent): %v", err) + } else { + cr.noMatchCount = len(noMatch) + } + + // 4. Query for a resource type that doesn't exist in the config. + unknown, err := req.Callbacks.GetResources("nonexistent_resource", cty.NullVal(cty.DynamicPseudoType)) + if err != nil { + t.Errorf("GetResources(nonexistent_resource): %v", err) + } else { + cr.unknownTypeCount = len(unknown) + } + + // Key by the ami attribute of the resource being evaluated. + ami := req.Attrs.GetAttr("ami").AsString() + mu.Lock() + results[ami] = cr + mu.Unlock() + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: policyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + var policyDiags tfdiags.Diagnostics + for _, result := range plan.PolicyResults.Iter() { + policyDiags = policyDiags.Append(result.EvaluationResponse.Diagnostics.AsTerraformDiags()) + } + tfdiags.AssertNoDiagnostics(t, policyDiags) + + // We expect exactly 3 evaluations (one per test_instance resource). + if len(results) != 3 { + t.Fatalf("expected 3 policy evaluations, got %d", len(results)) + } + + for ami, cr := range results { + var expectedTotal int + filteredCount := 1 + switch ami { + case "bar": + expectedTotal = 0 + filteredCount = 0 + case "qux": + expectedTotal = 1 + case "booper": + expectedTotal = 2 + } + if cr.matchAllCount != expectedTotal { + t.Errorf("evaluation[%s]: expected %d result for matchAll, got %d", ami, expectedTotal, cr.matchAllCount) + } + + // Filtering by ami="nonexistent" should always return 0 for all evaluations. + if cr.noMatchCount != 0 { + t.Errorf("evaluation[%s]: expected 0 results for ami=nonexistent filter, got %d", ami, cr.noMatchCount) + } + + // Querying for a non-existent resource type should always return 0. + if cr.unknownTypeCount != 0 { + t.Errorf("evaluation[%s]: expected 0 results for nonexistent_resource, got %d", ami, cr.unknownTypeCount) + } + + // The filtered result should only match one resource "bar", except when evaluating "bar" itself. + if cr.filteredCount != filteredCount { + t.Errorf("evaluation[%s]: expected filtered count %d, got %d", ami, filteredCount, cr.filteredCount) + } + } +} diff --git a/internal/terraform/context_test.go b/internal/terraform/context_test.go index ef4276874c7b..2f8cfa4da565 100644 --- a/internal/terraform/context_test.go +++ b/internal/terraform/context_test.go @@ -637,6 +637,10 @@ func testProviderSchema(name string) *providers.GetProviderSchemaResponse { Sensitive: true, Optional: true, }, + "defer": { + Type: cty.Bool, + Optional: true, + }, "random": { Type: cty.String, Optional: true, diff --git a/internal/terraform/context_walk.go b/internal/terraform/context_walk.go index 886fab9f12a2..1cb5a4dea5c1 100644 --- a/internal/terraform/context_walk.go +++ b/internal/terraform/context_walk.go @@ -12,12 +12,14 @@ import ( "github.com/hashicorp/terraform/internal/checks" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/deprecation" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/refactoring" "github.com/hashicorp/terraform/internal/resources/ephemeral" @@ -81,6 +83,11 @@ type graphWalkOpts struct { // Forget if set to true will cause the plan to forget all resources. This is // only allowd in the context of a destroy plan. Forget bool + + Locks map[addrs.Provider]*depsfile.ProviderLock + + PolicyClient policy.Client + PolicyResults *plans.PolicyResults } func (c *Context) walk(graph *Graph, operation walkOperation, opts *graphWalkOpts) (*ContextGraphWalker, tfdiags.Diagnostics) { @@ -202,6 +209,9 @@ func (c *Context) graphWalker(graph *Graph, operation walkOperation, opts *graph functionResults: opts.FunctionResults, Forget: opts.Forget, Actions: actions.NewActions(), + Locks: opts.Locks, + PolicyClient: opts.PolicyClient, + PolicyResults: opts.PolicyResults, Deprecations: deprecation.NewDeprecations(), } } diff --git a/internal/terraform/eval_context.go b/internal/terraform/eval_context.go index 7715fb2ad435..238a44020a2e 100644 --- a/internal/terraform/eval_context.go +++ b/internal/terraform/eval_context.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/deprecation" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/experiments" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -22,6 +23,7 @@ import ( "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" @@ -224,6 +226,14 @@ type EvalContext interface { // EvalContext. Actions() *actions.Actions + // ProviderLocks returns a read-only snapshot of provider locks (exact + // version per provider selected during init). + ProviderLocks() map[addrs.Provider]*depsfile.ProviderLock + + PolicyClient() policy.Client + PolicyResults() *plans.PolicyResults + + Config() *configs.Config // Deprecations returns the deprecations object that tracks meta-information // about deprecation, e.g. which module calls suppress deprecation warnings. Deprecations() *deprecation.Deprecations diff --git a/internal/terraform/eval_context_builtin.go b/internal/terraform/eval_context_builtin.go index 7e50ee8d2846..91fe2271aab2 100644 --- a/internal/terraform/eval_context_builtin.go +++ b/internal/terraform/eval_context_builtin.go @@ -19,6 +19,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/deprecation" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/experiments" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -26,6 +27,7 @@ import ( "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" @@ -94,9 +96,28 @@ type BuiltinEvalContext struct { MoveResultsValue refactoring.MoveResults OverrideValues *mocking.Overrides ActionsValue *actions.Actions + LocksValue map[addrs.Provider]*depsfile.ProviderLock + PolicyClientValue policy.Client + PolicyResultsValue *plans.PolicyResults DeprecationsValue *deprecation.Deprecations } +func (ctx *BuiltinEvalContext) ProviderLocks() map[addrs.Provider]*depsfile.ProviderLock { + return ctx.LocksValue +} + +func (ctx *BuiltinEvalContext) PolicyClient() policy.Client { + return ctx.PolicyClientValue +} + +func (ctx *BuiltinEvalContext) PolicyResults() *plans.PolicyResults { + return ctx.PolicyResultsValue +} + +func (ctx *BuiltinEvalContext) Config() *configs.Config { + return ctx.Evaluator.Config +} + // BuiltinEvalContext implements EvalContext var _ EvalContext = (*BuiltinEvalContext)(nil) diff --git a/internal/terraform/eval_context_mock.go b/internal/terraform/eval_context_mock.go index 9e3e7e1f0dec..a1c880c7e9d1 100644 --- a/internal/terraform/eval_context_mock.go +++ b/internal/terraform/eval_context_mock.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/deprecation" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/experiments" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" @@ -24,6 +25,7 @@ import ( "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" @@ -172,8 +174,12 @@ type MockEvalContext struct { ActionsCalled bool ActionsState *actions.Actions - DeprecationCalled bool - DeprecationState *deprecation.Deprecations + ProviderLocksValue map[addrs.Provider]*depsfile.ProviderLock + PolicyClientValue policy.Client + PolicyResultsValue *plans.PolicyResults + ConfigValue *configs.Config + DeprecationCalled bool + DeprecationState *deprecation.Deprecations } // MockEvalContext implements EvalContext @@ -458,6 +464,22 @@ func (c *MockEvalContext) Actions() *actions.Actions { return c.ActionsState } +func (c *MockEvalContext) ProviderLocks() map[addrs.Provider]*depsfile.ProviderLock { + return c.ProviderLocksValue +} + +func (c *MockEvalContext) PolicyClient() policy.Client { + return c.PolicyClientValue +} + +func (c *MockEvalContext) Config() *configs.Config { + return c.ConfigValue +} + +func (c *MockEvalContext) PolicyResults() *plans.PolicyResults { + return c.PolicyResultsValue +} + func (c *MockEvalContext) Deprecations() *deprecation.Deprecations { c.DeprecationCalled = true if c.DeprecationState != nil { diff --git a/internal/terraform/graph_builder_apply.go b/internal/terraform/graph_builder_apply.go index 08828604f44b..7fe3fb634822 100644 --- a/internal/terraform/graph_builder_apply.go +++ b/internal/terraform/graph_builder_apply.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/dag" "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" @@ -90,6 +91,9 @@ type ApplyGraphBuilder struct { // or test runtimes, where the root modules as Terraform sees them aren't // the actual root modules. AllowRootEphemeralOutputs bool + + // PolicyClient is the client for evaluating policies. + PolicyClient policy.Client } // See GraphBuilder @@ -142,6 +146,7 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { &ModuleVariableTransformer{ Config: b.Config, DestroyApply: b.Operation == walkDestroy, + PolicyClient: b.PolicyClient, }, &variableValidationTransformer{ operation: b.Operation, @@ -158,10 +163,11 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { // with dependency edges against the whole-resource nodes added by // ConfigTransformer above. &DiffTransformer{ - Concrete: concreteResourceInstance, - State: b.State, - Changes: b.Changes, - Config: b.Config, + Concrete: concreteResourceInstance, + State: b.State, + Changes: b.Changes, + Config: b.Config, + PolicyClient: b.PolicyClient, }, &ActionTriggerConfigTransformer{ @@ -212,7 +218,7 @@ func (b *ApplyGraphBuilder) Steps() []GraphTransformer { &AttachResourceConfigTransformer{Config: b.Config}, // add providers - transformProviders(concreteProvider, b.Config, b.ExternalProviderConfigs), + transformProviders(concreteProvider, b.Config, b.PolicyClient, b.ExternalProviderConfigs), // Remove modules no longer present in the config &RemovedModuleTransformer{Config: b.Config, State: b.State}, diff --git a/internal/terraform/graph_builder_eval.go b/internal/terraform/graph_builder_eval.go index ee8f8bcd285a..e28ab47ef006 100644 --- a/internal/terraform/graph_builder_eval.go +++ b/internal/terraform/graph_builder_eval.go @@ -91,7 +91,7 @@ func (b *EvalGraphBuilder) Steps() []GraphTransformer { // Attach the state &AttachStateTransformer{State: b.State}, - transformProviders(concreteProvider, b.Config, b.ExternalProviderConfigs), + transformProviders(concreteProvider, b.Config, nil, b.ExternalProviderConfigs), // Must attach schemas before ReferenceTransformer so that we can // analyze the configuration to find references. diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index 13d1c8e19e7a..c25b9d31aa38 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/dag" "github.com/hashicorp/terraform/internal/moduletest/mocking" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" @@ -117,6 +118,8 @@ type PlanGraphBuilder struct { // validation of the graph. SkipGraphValidation bool + PolicyClient policy.Client + // If true, the graph builder will generate a query plan instead of a // normal plan. This is used for the "terraform query" command. queryPlan bool @@ -203,6 +206,7 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { Config: b.Config, ValidateChecks: true, DestroyApply: false, // always false for planning + PolicyClient: b.PolicyClient, }, &variableValidationTransformer{ operation: b.Operation, @@ -263,7 +267,7 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { &AttachResourceConfigTransformer{Config: b.Config}, // add providers - transformProviders(b.ConcreteProvider, b.Config, b.ExternalProviderConfigs), + transformProviders(b.ConcreteProvider, b.Config, b.PolicyClient, b.ExternalProviderConfigs), // Remove modules no longer present in the config &RemovedModuleTransformer{Config: b.Config, State: b.State}, diff --git a/internal/terraform/graph_walk_context.go b/internal/terraform/graph_walk_context.go index 445398a31db1..8376b8b792bf 100644 --- a/internal/terraform/graph_walk_context.go +++ b/internal/terraform/graph_walk_context.go @@ -15,12 +15,14 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/deprecation" + "github.com/hashicorp/terraform/internal/depsfile" "github.com/hashicorp/terraform/internal/instances" "github.com/hashicorp/terraform/internal/lang" "github.com/hashicorp/terraform/internal/moduletest/mocking" "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/refactoring" @@ -64,6 +66,11 @@ type ContextGraphWalker struct { // is in progress. NonFatalDiagnostics tfdiags.Diagnostics + Locks map[addrs.Provider]*depsfile.ProviderLock + + PolicyClient policy.Client + PolicyResults *plans.PolicyResults + once sync.Once contexts collections.Map[evalContextScope, *BuiltinEvalContext] contextLock sync.Mutex @@ -147,6 +154,9 @@ func (w *ContextGraphWalker) EvalContext() EvalContext { OverrideValues: w.Overrides, forget: w.Forget, ActionsValue: w.Actions, + LocksValue: w.Locks, + PolicyClientValue: w.PolicyClient, + PolicyResultsValue: w.PolicyResults, DeprecationsValue: w.Deprecations, } diff --git a/internal/terraform/node_module_expand.go b/internal/terraform/node_module_expand.go index eb0099dc9c67..2cdfa05266c6 100644 --- a/internal/terraform/node_module_expand.go +++ b/internal/terraform/node_module_expand.go @@ -10,7 +10,10 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/dag" "github.com/hashicorp/terraform/internal/lang/langrefs" + "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/proto" "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" ) type ConcreteModuleNodeFunc func(n *nodeExpandModule) dag.Vertex @@ -22,6 +25,10 @@ type nodeExpandModule struct { Addr addrs.Module Config *configs.Module ModuleCall *configs.ModuleCall + + // ModuleTree is the configuration bundle within the source that this module call + // refers to. + ModuleTree *configs.Config } var ( @@ -160,6 +167,10 @@ func (n *nodeExpandModule) Execute(globalCtx EvalContext, op walkOperation) (dia } } + if !diags.HasErrors() { + return diags.Append(n.EvalPolicy(globalCtx, op)) + } + return diags } @@ -295,5 +306,53 @@ func (n *nodeValidateModule) Execute(globalCtx EvalContext, op walkOperation) (d expander.SetModuleSingle(module, call) } + if !diags.HasErrors() { + return diags.Append(n.EvalPolicy(globalCtx, op)) + } + return diags } + +func (n *nodeExpandModule) EvalPolicy(ctx EvalContext, op walkOperation) tfdiags.Diagnostics { + if ctx.PolicyClient() == nil { + log.Printf("[DEBUG] No policy client configured, skipping policy evaluation for %s", n.Addr) + return nil + } + + configBundle := n.ModuleTree + source := configBundle.SourceAddr.String() + + // Evaluate the module policy with just the metadata. Module variables cannot be sent here, + // because it is possible for them to depend on the module's output values, which are not available until after the module is expanded. + result := ctx.PolicyClient().EvaluateModule(ctx.StopCtx(), policy.EvaluationRequest[*proto.ModuleMetadata]{ + Attrs: cty.NilVal, + Target: source, + Meta: &proto.ModuleMetadata{ + Address: n.Addr.String(), + Source: source, + Version: func() string { + if configBundle.Version == nil { + return "" + } + return configBundle.Version.String() + }(), + }, + }) + + if n.ModuleCall.Config != nil { + ptr := n.ModuleCall.DeclRange.Ptr() + for idx, diag := range result.Diagnostics { + result.Diagnostics[idx] = diag.WithLocalRange(ptr) + } + for idx := range result.Enforcements { + result.Enforcements[idx].LocalRange = ptr + } + } + + // always add the result to the policy results + if ctx.PolicyResults() != nil { + ctx.PolicyResults().AddModule(n.Addr, result, n.ModuleCall) + } + + return nil +} diff --git a/internal/terraform/node_provider.go b/internal/terraform/node_provider.go index 9392eef33c26..3bd413ffe0d3 100644 --- a/internal/terraform/node_provider.go +++ b/internal/terraform/node_provider.go @@ -11,6 +11,8 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/proto" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -40,18 +42,18 @@ func (n *NodeApplyableProvider) Execute(ctx EvalContext, op walkOperation) (diag switch op { case walkValidate: log.Printf("[TRACE] NodeApplyableProvider: validating configuration for %s", n.Addr) - return diags.Append(n.ValidateProvider(ctx, provider)) + return diags.Append(n.ValidateProvider(ctx, op, provider)) case walkPlan, walkPlanDestroy, walkApply, walkDestroy: log.Printf("[TRACE] NodeApplyableProvider: configuring %s", n.Addr) - return diags.Append(n.ConfigureProvider(ctx, provider, false)) + return diags.Append(n.ConfigureProvider(ctx, op, provider, false)) case walkImport: log.Printf("[TRACE] NodeApplyableProvider: configuring %s (requiring that configuration is wholly known)", n.Addr) - return diags.Append(n.ConfigureProvider(ctx, provider, true)) + return diags.Append(n.ConfigureProvider(ctx, op, provider, true)) } return diags } -func (n *NodeApplyableProvider) ValidateProvider(ctx EvalContext, provider providers.Interface) (diags tfdiags.Diagnostics) { +func (n *NodeApplyableProvider) ValidateProvider(ctx EvalContext, op walkOperation, provider providers.Interface) (diags tfdiags.Diagnostics) { configBody := buildProviderConfig(ctx, n.Addr, n.ProviderConfig()) @@ -107,7 +109,7 @@ func (n *NodeApplyableProvider) ValidateProvider(ctx EvalContext, provider provi // ConfigureProvider configures a provider that is already initialized and retrieved. // If verifyConfigIsKnown is true, ConfigureProvider will return an error if the // provider configVal is not wholly known and is meant only for use during import. -func (n *NodeApplyableProvider) ConfigureProvider(ctx EvalContext, provider providers.Interface, verifyConfigIsKnown bool) (diags tfdiags.Diagnostics) { +func (n *NodeApplyableProvider) ConfigureProvider(ctx EvalContext, op walkOperation, provider providers.Interface, verifyConfigIsKnown bool) (diags tfdiags.Diagnostics) { config := n.ProviderConfig() configBody := buildProviderConfig(ctx, n.Addr, config) @@ -155,12 +157,12 @@ func (n *NodeApplyableProvider) ConfigureProvider(ctx EvalContext, provider prov // If our config value contains any marked values, ensure those are // stripped out before sending this to the provider - unmarkedConfigVal, _ := configVal.UnmarkDeep() + n.cachedUnmarkedConfigValue, _ = configVal.UnmarkDeep() // Allow the provider to validate and insert any defaults into the full // configuration. req := providers.ValidateProviderConfigRequest{ - Config: unmarkedConfigVal, + Config: n.cachedUnmarkedConfigValue, } // ValidateProviderConfig is only used for validation. We are intentionally @@ -185,11 +187,11 @@ func (n *NodeApplyableProvider) ConfigureProvider(ctx EvalContext, provider prov // If the provider returns something different, log a warning to help // indicate to provider developers that the value is not used. preparedCfg := validateResp.PreparedConfig - if preparedCfg != cty.NilVal && !preparedCfg.IsNull() && !preparedCfg.RawEquals(unmarkedConfigVal) { + if preparedCfg != cty.NilVal && !preparedCfg.IsNull() && !preparedCfg.RawEquals(n.cachedUnmarkedConfigValue) { log.Printf("[WARN] ValidateProviderConfig from %q changed the config value, but that value is unused", n.Addr) } - configDiags := ctx.ConfigureProvider(n.Addr, unmarkedConfigVal) + configDiags := ctx.ConfigureProvider(n.Addr, n.cachedUnmarkedConfigValue) diags = diags.Append(configDiags.InConfigBody(configBody, n.Addr.String())) if diags.HasErrors() && config == nil { // If there isn't an explicit "provider" block in the configuration, @@ -201,9 +203,68 @@ func (n *NodeApplyableProvider) ConfigureProvider(ctx EvalContext, provider prov fmt.Sprintf(providerConfigErr, n.Addr.Provider), )) } + + // Post-provider config policy evaluation + policyDiags := n.EvalPolicy(ctx, op, n.cachedUnmarkedConfigValue) + diags = diags.Append(policyDiags) + if policyDiags.HasErrors() { + return diags + } + return diags } +func (n *NodeApplyableProvider) EvalPolicy(ctx EvalContext, op walkOperation, attrs cty.Value) tfdiags.Diagnostics { + if ctx.PolicyClient() == nil { + log.Printf("[DEBUG] No policy client configured, skipping policy evaluation for %s", n.Addr) + return nil + } + result := ctx.PolicyClient().EvaluateProvider(ctx.StopCtx(), policy.EvaluationRequest[*proto.ProviderMetadata]{ + Target: n.Addr.Provider.Type, + Attrs: attrs, + Meta: &proto.ProviderMetadata{ + Name: n.Addr.Provider.Type, + Alias: n.Addr.Alias, + Type: n.Addr.Provider.Type, + Namespace: n.Addr.Provider.Namespace, + Source: n.Addr.Provider.String(), + ModulePath: n.Addr.Module.String(), + Version: n.providerVersion(ctx), + }, + }) + + // if this was an "implicit provider", and we have no configuration + // for it, There's going to be no source information for these errors. + if n.Config != nil { + ptr := n.Config.DeclRange.Ptr() + for idx, diag := range result.Diagnostics { + result.Diagnostics[idx] = diag.WithLocalRange(ptr) + } + for idx := range result.Enforcements { + result.Enforcements[idx].LocalRange = ptr + } + } + + // always add the result to the policy results + if ctx.PolicyResults() != nil { + ctx.PolicyResults().AddProvider(n.Addr, result, n.Config) + } + + return nil +} + +// providerVersion returns the exact locked version for this provider from the +// dependency lock file (e.g. "5.31.0"). Returns an empty string if no lock +// file entry is available for this provider. +func (n *NodeApplyableProvider) providerVersion(ctx EvalContext) string { + if providerLocks := ctx.ProviderLocks(); providerLocks != nil { + if lock := providerLocks[n.Addr.Provider]; lock != nil { + return lock.Version().String() + } + } + return "" +} + // nodeExternalProvider is used instead of [NodeApplyableProvider] when an // already-configured provider instance has been provided by an external caller, // and therefore we don't need to do anything to get the provider ready to diff --git a/internal/terraform/node_provider_abstract.go b/internal/terraform/node_provider_abstract.go index 06064bb05837..cc68ab8ed8f0 100644 --- a/internal/terraform/node_provider_abstract.go +++ b/internal/terraform/node_provider_abstract.go @@ -4,6 +4,8 @@ package terraform import ( + "github.com/zclconf/go-cty/cty" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -26,6 +28,10 @@ type NodeAbstractProvider struct { Config *configs.Provider Schema *configschema.Block + + // cachedUnmarkedConfigValue is set when the provider is configured, so + // that other nodes don't need to recalculate the config value. + cachedUnmarkedConfigValue cty.Value } var ( diff --git a/internal/terraform/node_provider_test.go b/internal/terraform/node_provider_test.go index ff152098c57c..1f8a0fe067ce 100644 --- a/internal/terraform/node_provider_test.go +++ b/internal/terraform/node_provider_test.go @@ -14,7 +14,10 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/depsfile" + "github.com/hashicorp/terraform/internal/getproviders/providerreqs" "github.com/hashicorp/terraform/internal/lang/marks" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" "github.com/hashicorp/terraform/internal/tfdiags" @@ -287,7 +290,7 @@ func TestNodeApplyableProvider_Validate(t *testing.T) { }, } - diags := node.ValidateProvider(ctx, provider) + diags := node.ValidateProvider(ctx, walkPlan, provider) if diags.HasErrors() { t.Errorf("unexpected error with valid config: %s", diags.Err()) } @@ -308,7 +311,7 @@ func TestNodeApplyableProvider_Validate(t *testing.T) { }, } - diags := node.ValidateProvider(ctx, provider) + diags := node.ValidateProvider(ctx, walkPlan, provider) if !diags.HasErrors() { t.Error("missing expected error with invalid config") } @@ -321,7 +324,7 @@ func TestNodeApplyableProvider_Validate(t *testing.T) { }, } - diags := node.ValidateProvider(ctx, provider) + diags := node.ValidateProvider(ctx, walkPlan, provider) if diags.HasErrors() { t.Errorf("unexpected error with empty config: %s", diags.Err()) } @@ -382,7 +385,7 @@ func TestNodeApplyableProvider_ConfigProvider(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) if diags.HasErrors() { t.Errorf("unexpected error with valid config: %s", diags.Err()) } @@ -395,7 +398,7 @@ func TestNodeApplyableProvider_ConfigProvider(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) if !diags.HasErrors() { t.Fatal("missing expected error with nil config") } @@ -416,7 +419,7 @@ func TestNodeApplyableProvider_ConfigProvider(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) if !diags.HasErrors() { t.Fatal("missing expected error with invalid config") } @@ -432,7 +435,7 @@ func TestNodeApplyableProvider_ConfigProvider(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, requiredProvider, false) + diags := node.ConfigureProvider(ctx, walkPlan, requiredProvider, false) if !diags.HasErrors() { t.Fatal("missing expected error with nil config") } @@ -453,7 +456,7 @@ func TestNodeApplyableProvider_ConfigProvider(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, requiredProvider, false) + diags := node.ConfigureProvider(ctx, walkPlan, requiredProvider, false) if !diags.HasErrors() { t.Fatal("missing expected error with invalid config") } @@ -508,7 +511,7 @@ func TestNodeApplyableProvider_ConfigProvider_config_fn_err(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) if diags.HasErrors() { t.Errorf("unexpected error with valid config: %s", diags.Err()) } @@ -521,7 +524,7 @@ func TestNodeApplyableProvider_ConfigProvider_config_fn_err(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) if !diags.HasErrors() { t.Fatal("missing expected error with nil config") } @@ -542,7 +545,7 @@ func TestNodeApplyableProvider_ConfigProvider_config_fn_err(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) if !diags.HasErrors() { t.Fatal("missing expected error with invalid config") } @@ -568,12 +571,209 @@ func TestGetSchemaError(t *testing.T) { }, } - diags := node.ConfigureProvider(ctx, provider, false) + diags := node.ConfigureProvider(ctx, walkPlan, provider, false) for _, d := range diags { desc := d.Description() if desc.Address != providerAddr.String() { t.Fatalf("missing provider address from diagnostics: %#v", desc) } } +} + +func TestNodeApplyableProvider_providerVersion(t *testing.T) { + providerAddr := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("aws"), + } + + node := &NodeApplyableProvider{ + NodeAbstractProvider: &NodeAbstractProvider{ + Addr: providerAddr, + }, + } + + t.Run("with locked version", func(t *testing.T) { + lock := depsfile.NewProviderLock( + addrs.NewDefaultProvider("aws"), + providerreqs.MustParseVersion("5.31.0"), + nil, + nil, + ) + ctx := &MockEvalContext{ + ProviderLocksValue: map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("aws"): lock, + }, + } + + got := node.providerVersion(ctx) + if got != "5.31.0" { + t.Errorf("wrong version\ngot: %s\nwant: 5.31.0", got) + } + }) + + t.Run("no lock file", func(t *testing.T) { + ctx := &MockEvalContext{} + + got := node.providerVersion(ctx) + if got != "" { + t.Errorf("expected empty version, got: %s", got) + } + }) + + t.Run("lock file without this provider", func(t *testing.T) { + lock := depsfile.NewProviderLock( + addrs.NewDefaultProvider("google"), + providerreqs.MustParseVersion("4.0.0"), + nil, + nil, + ) + ctx := &MockEvalContext{ + ProviderLocksValue: map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("google"): lock, + }, + } + + got := node.providerVersion(ctx) + if got != "" { + t.Errorf("expected empty version, got: %s", got) + } + }) +} + +func TestNodeApplyableProvider_EvalPolicy_versionMeta(t *testing.T) { + providerAddr := addrs.AbsProviderConfig{ + Module: addrs.RootModule, + Provider: addrs.NewDefaultProvider("aws"), + } + + t.Run("version from lock file is passed in meta", func(t *testing.T) { + lock := depsfile.NewProviderLock( + addrs.NewDefaultProvider("aws"), + providerreqs.MustParseVersion("5.31.0"), + nil, + nil, + ) + + mockPolicy := &policy.MockClient{} + + ctx := &MockEvalContext{ + ProviderLocksValue: map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("aws"): lock, + }, + PolicyClientValue: mockPolicy, + } + + node := &NodeApplyableProvider{ + NodeAbstractProvider: &NodeAbstractProvider{ + Addr: providerAddr, + }, + } + node.EvalPolicy(ctx, walkPlan, cty.EmptyObjectVal) + + if !mockPolicy.EvaluateProviderCalled { + t.Fatal("EvaluateProvider was not called") + } + + meta := mockPolicy.EvaluateProviderRequest.Meta + + gotVersion := meta.Version + if gotVersion != "5.31.0" { + t.Errorf("wrong version in meta\ngot: %s\nwant: 5.31.0", gotVersion) + } + }) + + t.Run("empty version when no lock file", func(t *testing.T) { + mockPolicy := &policy.MockClient{} + + ctx := &MockEvalContext{ + PolicyClientValue: mockPolicy, + } + + node := &NodeApplyableProvider{ + NodeAbstractProvider: &NodeAbstractProvider{ + Addr: providerAddr, + }, + } + + node.EvalPolicy(ctx, walkPlan, cty.EmptyObjectVal) + + if !mockPolicy.EvaluateProviderCalled { + t.Fatal("EvaluateProvider was not called") + } + + meta := mockPolicy.EvaluateProviderRequest.Meta + gotVersion := meta.Version + if gotVersion != "" { + t.Errorf("expected empty version in meta, got: %s", gotVersion) + } + }) + + t.Run("no policy client skips evaluation", func(t *testing.T) { + ctx := &MockEvalContext{} + + node := &NodeApplyableProvider{ + NodeAbstractProvider: &NodeAbstractProvider{ + Addr: providerAddr, + }, + } + + diags := node.EvalPolicy(ctx, walkPlan, cty.EmptyObjectVal) + if diags != nil { + t.Fatalf("unexpected diagnostics: %s", diags.Err()) + } + }) + + t.Run("meta contains all expected fields", func(t *testing.T) { + lock := depsfile.NewProviderLock( + addrs.NewDefaultProvider("aws"), + providerreqs.MustParseVersion("5.31.0"), + nil, + nil, + ) + + mockPolicy := &policy.MockClient{} + + ctx := &MockEvalContext{ + ProviderLocksValue: map[addrs.Provider]*depsfile.ProviderLock{ + addrs.NewDefaultProvider("aws"): lock, + }, + PolicyClientValue: mockPolicy, + } + + node := &NodeApplyableProvider{ + NodeAbstractProvider: &NodeAbstractProvider{ + Addr: providerAddr, + }, + } + + node.EvalPolicy(ctx, walkPlan, cty.EmptyObjectVal) + + meta := mockPolicy.EvaluateProviderRequest.Meta + + checks := map[string]string{ + "name": meta.Name, + "alias": meta.Alias, + "type": meta.Type, + "namespace": meta.Namespace, + "source": meta.Source, + "module_path": meta.ModulePath, + "version": meta.Version, + } + expected := map[string]string{ + "name": "aws", + "alias": "", + "type": "aws", + "namespace": "hashicorp", + "source": "registry.terraform.io/hashicorp/aws", + "module_path": "", + "version": "5.31.0", + } + for field, got := range checks { + want := expected[field] + if got != want { + t.Errorf("wrong meta %q\ngot: %s\nwant: %s", field, got, want) + } + } + }) } diff --git a/internal/terraform/node_resource_abstract_instance.go b/internal/terraform/node_resource_abstract_instance.go index 2ff8e5b6c1dd..9b739596474a 100644 --- a/internal/terraform/node_resource_abstract_instance.go +++ b/internal/terraform/node_resource_abstract_instance.go @@ -24,6 +24,8 @@ import ( "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans/deferring" "github.com/hashicorp/terraform/internal/plans/objchange" + "github.com/hashicorp/terraform/internal/policy/callback" + "github.com/hashicorp/terraform/internal/policy/proto" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/provisioners" "github.com/hashicorp/terraform/internal/states" @@ -223,13 +225,15 @@ func (n *NodeAbstractResourceInstance) preApplyHook(ctx EvalContext, change *pla if diags.HasErrors() { return diags } + + // TODO(sams): Implement pre-apply policy evaluation } return nil } // postApplyHook calls the post-Apply hook -func (n *NodeAbstractResourceInstance) postApplyHook(ctx EvalContext, state *states.ResourceInstanceObject, err error) tfdiags.Diagnostics { +func (n *NodeAbstractResourceInstance) postApplyHook(ctx EvalContext, action plans.Action, state *states.ResourceInstanceObject, priorState cty.Value, err error) tfdiags.Diagnostics { var diags tfdiags.Diagnostics // Only managed resources have user-visible apply actions. @@ -243,6 +247,13 @@ func (n *NodeAbstractResourceInstance) postApplyHook(ctx EvalContext, state *sta diags = diags.Append(ctx.Hook(func(h Hook) (HookAction, error) { return h.PostApply(n.HookResourceIdentity(), addrs.NotDeposed, newState, err) })) + + // Post-apply policy evaluation + policyDiags := n.EvalPolicy(ctx, walkApply, action, newState, priorState) + diags = diags.Append(policyDiags) + if policyDiags.HasErrors() { + return diags + } } return diags @@ -421,7 +432,7 @@ func (n *NodeAbstractResourceInstance) planDestroy(ctx EvalContext, currentState return plan, deferred, diags.Append(err) } - metaConfigVal, metaDiags := n.providerMetas(ctx) + metaConfigVal, metaDiags := n.Provider().getProviderMeta(ctx, n.Addr.Resource, n.ProviderMetas) diags = diags.Append(metaDiags) if diags.HasErrors() { return plan, deferred, diags @@ -499,6 +510,13 @@ func (n *NodeAbstractResourceInstance) planDestroy(ctx EvalContext, currentState } } + // Post-plan policy evaluation + policyDiags := n.EvalPolicy(ctx, walkPlan, plans.Delete, nullVal, currentState.Value) + diags = diags.Append(policyDiags) + if policyDiags.HasErrors() { + return plan, deferred, diags + } + // Call post-refresh hook diags = diags.Append(ctx.Hook(func(h Hook) (HookAction, error) { return h.PostDiff(n.HookResourceIdentity(), deposedKey, plans.Delete, currentState.Value, nullVal, nil) @@ -638,7 +656,7 @@ func (n *NodeAbstractResourceInstance) refresh(ctx EvalContext, deposedKey state return state, deferred, diags } - metaConfigVal, metaDiags := n.providerMetas(ctx) + metaConfigVal, metaDiags := n.Provider().getProviderMeta(ctx, n.Addr.Resource, n.ProviderMetas) diags = diags.Append(metaDiags) if diags.HasErrors() { return state, deferred, diags @@ -881,7 +899,7 @@ func (n *NodeAbstractResourceInstance) plan( return nil, nil, deferred, keyData, diags } - metaConfigVal, metaDiags := n.providerMetas(ctx) + metaConfigVal, metaDiags := n.Provider().getProviderMeta(ctx, n.Addr.Resource, n.ProviderMetas) diags = diags.Append(metaDiags) if diags.HasErrors() { return nil, nil, deferred, keyData, diags @@ -1648,7 +1666,7 @@ func (n *NodeAbstractResourceInstance) readDataSource(ctx EvalContext, configVal return newVal, deferred, diags } - metaConfigVal, metaDiags := n.providerMetas(ctx) + metaConfigVal, metaDiags := n.Provider().getProviderMeta(ctx, n.Addr.Resource, n.ProviderMetas) diags = diags.Append(metaDiags) if diags.HasErrors() { return newVal, deferred, diags @@ -1779,37 +1797,6 @@ func (n *NodeAbstractResourceInstance) readDataSource(ctx EvalContext, configVal return newVal, deferred, diags } -func (n *NodeAbstractResourceInstance) providerMetas(ctx EvalContext) (cty.Value, tfdiags.Diagnostics) { - var diags tfdiags.Diagnostics - metaConfigVal := cty.NullVal(cty.DynamicPseudoType) - - _, providerSchema, err := getProvider(ctx, n.ResolvedProvider) - if err != nil { - return metaConfigVal, diags.Append(err) - } - if n.ProviderMetas != nil { - if m, ok := n.ProviderMetas[n.ResolvedProvider.Provider]; ok && m != nil { - // if the provider doesn't support this feature, throw an error - if providerSchema.ProviderMeta.Body == nil { - diags = diags.Append(&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: fmt.Sprintf("Provider %s doesn't support provider_meta", n.ResolvedProvider.Provider.String()), - Detail: fmt.Sprintf("The resource %s belongs to a provider that doesn't support provider_meta blocks", n.Addr.Resource), - Subject: &m.ProviderRange, - }) - } else { - var configDiags tfdiags.Diagnostics - metaConfigVal, _, configDiags = ctx.EvaluateBlock(m.Config, providerSchema.ProviderMeta.Body, nil, EvalDataForNoInstanceKey) - diags = diags.Append(configDiags) - var deprecationDiags tfdiags.Diagnostics - metaConfigVal, deprecationDiags = ctx.Deprecations().ValidateAndUnmarkConfig(metaConfigVal, providerSchema.ProviderMeta.Body, ctx.Path().Module()) - diags = diags.Append(deprecationDiags.InConfigBody(m.Config, n.Addr.String())) - } - } - } - return metaConfigVal, diags -} - // planDataSource deals with the main part of the data resource lifecycle: // either actually reading from the data source or generating a plan to do so. // @@ -2638,7 +2625,7 @@ func (n *NodeAbstractResourceInstance) apply( return state, diags } - metaConfigVal, metaDiags := n.providerMetas(ctx) + metaConfigVal, metaDiags := n.Provider().getProviderMeta(ctx, n.Addr.Resource, n.ProviderMetas) diags = diags.Append(metaDiags) if diags.HasErrors() { return state, diags @@ -3135,3 +3122,60 @@ func getRequiredReplaces(priorVal, plannedNewVal cty.Value, writeOnly []cty.Path return reqRep, diags } + +func (n *NodeAbstractResourceInstance) EvalPolicy(ctx EvalContext, walkOperation walkOperation, action plans.Action, state cty.Value, priorState cty.Value) tfdiags.Diagnostics { + if ctx.PolicyClient() == nil { + log.Printf("[DEBUG] No policy client configured, skipping policy evaluation for %s", n.Addr) + return nil + } + + // for now, we unmark values before handing them over to policy + attrs, _ := state.UnmarkDeep() + priorAttrs, _ := priorState.UnmarkDeep() + provider, schema, err := getProvider(ctx, n.ResolvedProvider) + var diags tfdiags.Diagnostics + if err != nil { + diags = diags.Append(err) + return diags + } + + var actionStr proto.Operation + switch action { + case plans.Create: + actionStr = proto.Operation_CREATE + case plans.Delete: + actionStr = proto.Operation_DELETE + case plans.Update, + plans.DeleteThenCreate, + plans.CreateThenDelete, + plans.CreateThenForget: + actionStr = proto.Operation_UPDATE + default: + // No-op for non-CUD actions + return nil + } + + meta := &proto.ResourceMetadata{ + Operation: actionStr, + Type: n.Addr.Resource.Resource.Type, + ProviderType: n.ResolvedProvider.Provider.Type, + } + + providerMeta, metaDiags := n.Provider().getProviderMeta(ctx, n.Addr.Resource, n.ProviderMetas) + diags = diags.Append(metaDiags) + if diags.HasErrors() { + return diags + } + + callbacks := callback.Functions{ + GetResources: getResourcesForPolicyCallback(ctx, ctx.Config()), + GetDataSource: getDataSourceForPolicyCallback(ctx, provider, schema, providerMeta), + } + + resource := ctx.Config().Descendant(n.Path().Module()).Module.ResourceByAddr(n.Addr.Resource.Resource) + result := evaluatePolicies(ctx, walkOperation, n.Addr, resource, ctx.PolicyClient(), attrs, priorAttrs, meta, callbacks) + if ctx.PolicyResults() != nil { + ctx.PolicyResults().AddResource(n.Addr, result, resource) + } + return nil +} diff --git a/internal/terraform/node_resource_apply_instance.go b/internal/terraform/node_resource_apply_instance.go index f4b618fcd776..ee68638c68ea 100644 --- a/internal/terraform/node_resource_apply_instance.go +++ b/internal/terraform/node_resource_apply_instance.go @@ -377,7 +377,7 @@ func (n *NodeApplyableResourceInstance) managedResourceExecute(ctx EvalContext) } } - diags = diags.Append(n.postApplyHook(ctx, state, diags.Err())) + diags = diags.Append(n.postApplyHook(ctx, diffApply.Action, state, diffApply.Change.Before, diags.Err())) diags = diags.Append(updateStateHook(ctx)) // Post-conditions might block further progress. We intentionally do this diff --git a/internal/terraform/node_resource_destroy.go b/internal/terraform/node_resource_destroy.go index 16b195be7c63..38ea80fa8d6e 100644 --- a/internal/terraform/node_resource_destroy.go +++ b/internal/terraform/node_resource_destroy.go @@ -200,7 +200,7 @@ func (n *NodeDestroyResourceInstance) managedResourceExecute(ctx EvalContext) (d if diags.HasErrors() { // If we have a provisioning error, then we just call // the post-apply hook now. - diags = diags.Append(n.postApplyHook(ctx, state, diags.Err())) + diags = diags.Append(n.postApplyHook(ctx, plans.Delete, state, changeApply.Change.Before, diags.Err())) return diags } } @@ -219,7 +219,7 @@ func (n *NodeDestroyResourceInstance) managedResourceExecute(ctx EvalContext) (d } // create the err value for postApplyHook - diags = diags.Append(n.postApplyHook(ctx, state, diags.Err())) + diags = diags.Append(n.postApplyHook(ctx, changeApply.Action, state, changeApply.Change.Before, diags.Err())) diags = diags.Append(updateStateHook(ctx)) return diags } diff --git a/internal/terraform/node_resource_destroy_deposed.go b/internal/terraform/node_resource_destroy_deposed.go index 907f50f14b80..c346e634c392 100644 --- a/internal/terraform/node_resource_destroy_deposed.go +++ b/internal/terraform/node_resource_destroy_deposed.go @@ -326,7 +326,7 @@ func (n *NodeDestroyDeposedResourceInstanceObject) Execute(ctx EvalContext, op w return diags } - diags = diags.Append(n.postApplyHook(ctx, state, diags.Err())) + diags = diags.Append(n.postApplyHook(ctx, change.Action, state, change.Change.Before, diags.Err())) return diags.Append(updateStateHook(ctx)) } diff --git a/internal/terraform/node_resource_plan.go b/internal/terraform/node_resource_plan.go index 9815cb0cb1ea..bee97ca6a440 100644 --- a/internal/terraform/node_resource_plan.go +++ b/internal/terraform/node_resource_plan.go @@ -373,6 +373,7 @@ func (n *nodeExpandPlannableResource) dynamicExpand(ctx EvalContext, moduleInsta orphans := n.findOrphans(ctx, moduleInstances) + // TODO: orphaned resource instances too? for _, res := range orphans { for key := range res.Instances { addr := res.Addr.Instance(key) diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index 41dfe0f59533..e30188909d89 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -432,6 +432,19 @@ func (n *NodePlannableResourceInstance) managedResourceExecute(ctx EvalContext) change.ActionReason = plans.ResourceInstanceReplaceByTriggers } + // Post-plan policy evaluation + // If this plan is a pre-destroy refresh, we do not evaluate policy + // so that we don't end up evaluating policy for the to-be-destroyed state. + // A post-plan policy evaluation will be performed from NodePlanDestroyableResourceInstance. + // If the resource is deferred, we also do not evaluate policy. + if !n.preDestroyRefresh && deferred == nil { + policyDiags := n.EvalPolicy(ctx, walkPlan, change.Action, change.After, change.Before) + diags = diags.Append(policyDiags) + if policyDiags.HasErrors() { + return diags + } + } + deferrals := ctx.Deferrals() if deferred != nil { // Then this resource has been deferred either during the import, diff --git a/internal/terraform/policy.go b/internal/terraform/policy.go new file mode 100644 index 000000000000..8fe37f516963 --- /dev/null +++ b/internal/terraform/policy.go @@ -0,0 +1,122 @@ +// Copyright IBM Corp. 2014, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/callback" + "github.com/hashicorp/terraform/internal/policy/proto" + "github.com/hashicorp/terraform/internal/providers" +) + +func evaluatePolicies(ctx EvalContext, walkOperation walkOperation, target addrs.AbsResourceInstance, config *configs.Resource, client policy.Client, attrs, priorAttrs cty.Value, meta *proto.ResourceMetadata, callbacks callback.Functions) policy.EvaluationResponse { + result := client.Evaluate(ctx.StopCtx(), policy.EvaluationRequest[*proto.ResourceMetadata]{ + Target: target.Resource.Resource.Type, + Attrs: attrs, + PriorAttrs: priorAttrs, + Meta: meta, + Callbacks: callbacks, + }) + + // orphaned resources do not have a config, so we can't provide source information + // for these errors. + if config != nil { + ptr := config.DeclRange.Ptr() + for idx, diag := range result.Diagnostics { + result.Diagnostics[idx] = diag.WithLocalRange(ptr) + } + for idx := range result.Enforcements { + result.Enforcements[idx].LocalRange = ptr + } + } + + return result +} + +func getResourcesForPolicyCallback(ctx EvalContext, config *configs.Config) func(target string, attrs cty.Value) ([]cty.Value, error) { + return func(target string, attrs cty.Value) ([]cty.Value, error) { + var found []cty.Value + config.DeepEach(func(c *configs.Config) { + for _, resource := range c.Module.ManagedResources { + if resource.Type != target { + continue + } + + resources := ctx.Changes().GetChangesForConfigResource(resource.Addr().InModule(c.Path)) + for _, change := range resources { + resource := change.After + if attrs.IsNull() { + // then match everything + found = append(found, resource) + continue + } + + value, matched := resource, true + for name, attr := range attrs.AsValueMap() { + if !value.Type().HasAttribute(name) { + matched = false + break + } + + equals := attr.Equals(value.GetAttr(name)) + if !equals.IsKnown() { + // We'll treat unknown values as matches, and they + // can be handled on the Terraform Policy side. + continue + } + + if equals.False() { + matched = false + break + } + } + + if matched { + value, _ = value.UnmarkDeep() + found = append(found, value) + } + + } + } + }) + return found, nil + } +} + +func getDataSourceForPolicyCallback(ctx EvalContext, provider providers.Interface, schema providers.GetProviderSchemaResponse, meta cty.Value) func(datasource string, attrs cty.Value) (cty.Value, error) { + return func(target string, attrs cty.Value) (cty.Value, error) { + if datasource, ok := schema.DataSources[target]; ok { + configVal, err := datasource.Body.CoerceValue(attrs) + if err != nil { + return cty.NilVal, fmt.Errorf("invalid attributes for %q: %w", target, err) + } + + validateResp := provider.ValidateDataResourceConfig(providers.ValidateDataResourceConfigRequest{ + TypeName: target, + Config: configVal, + }) + if err := validateResp.Diagnostics.Err(); err != nil { + return cty.NilVal, fmt.Errorf("failed to validate data source configuration: %s", err) + } + + readResp := provider.ReadDataSource(providers.ReadDataSourceRequest{ + TypeName: target, + Config: configVal, + ProviderMeta: meta, + }) + if err := readResp.Diagnostics.Err(); err != nil { + return cty.NilVal, fmt.Errorf("failed to read data source: %s", err) + } + + return readResp.State, nil + } + return cty.NilVal, fmt.Errorf("no data source found for %s", target) + } +} diff --git a/internal/terraform/transform_diff.go b/internal/terraform/transform_diff.go index bc17068f11cd..74edc8791990 100644 --- a/internal/terraform/transform_diff.go +++ b/internal/terraform/transform_diff.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/dag" "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/tfdiags" ) @@ -22,6 +23,8 @@ type DiffTransformer struct { State *states.State Changes *plans.ChangesSrc Config *configs.Config + + PolicyClient policy.Client } // return true if the given resource instance has either Preconditions or diff --git a/internal/terraform/transform_module_expansion.go b/internal/terraform/transform_module_expansion.go index b5b524130e0b..4edecba59bba 100644 --- a/internal/terraform/transform_module_expansion.go +++ b/internal/terraform/transform_module_expansion.go @@ -91,6 +91,7 @@ func (t *ModuleExpansionTransformer) transform(g *Graph, c *configs.Config, pare Addr: c.Path, Config: c.Module, ModuleCall: modCall, + ModuleTree: c, } var expander dag.Vertex = n if t.Concrete != nil { diff --git a/internal/terraform/transform_module_variable.go b/internal/terraform/transform_module_variable.go index fe7987467efb..b6a339aa51cc 100644 --- a/internal/terraform/transform_module_variable.go +++ b/internal/terraform/transform_module_variable.go @@ -9,6 +9,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/hashicorp/hcl/v2" @@ -39,6 +40,8 @@ type ModuleVariableTransformer struct { // DestroyApply must be set to true when applying a destroy operation and // false otherwise. DestroyApply bool + + PolicyClient policy.Client } func (t *ModuleVariableTransformer) Transform(g *Graph) error { diff --git a/internal/terraform/transform_provider.go b/internal/terraform/transform_provider.go index 2e89b35458fb..d23bcf6502ba 100644 --- a/internal/terraform/transform_provider.go +++ b/internal/terraform/transform_provider.go @@ -8,15 +8,17 @@ import ( "log" "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/dag" + "github.com/hashicorp/terraform/internal/policy" "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/tfdiags" ) -func transformProviders(concrete ConcreteProviderNodeFunc, config *configs.Config, externalProviderConfigs map[addrs.RootProviderConfig]providers.Interface) GraphTransformer { +func transformProviders(concrete ConcreteProviderNodeFunc, config *configs.Config, policyClient policy.Client, externalProviderConfigs map[addrs.RootProviderConfig]providers.Interface) GraphTransformer { return GraphTransformMulti( // Add placeholder nodes for any externally-configured providers &externalProviderTransformer{ @@ -127,6 +129,38 @@ func (r ProviderRef) ForDisplay() string { return r.addr.Provider.ForDisplay() } +func (r ProviderRef) getProviderMeta(ctx EvalContext, resource addrs.ResourceInstance, metas map[addrs.Provider]*configs.ProviderMeta) (cty.Value, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + metaConfigVal := cty.NullVal(cty.DynamicPseudoType) + + _, providerSchema, err := getProvider(ctx, r.addr) + if err != nil { + return metaConfigVal, diags.Append(err) + } + + if metas != nil { + if m, ok := metas[r.addr.Provider]; ok && m != nil { + // if the provider doesn't support this feature, throw an error + if providerSchema.ProviderMeta.Body == nil { + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Provider %s doesn't support provider_meta", r.addr.Provider.String()), + Detail: fmt.Sprintf("The resource %s belongs to a provider that doesn't support provider_meta blocks", resource.String()), + Subject: &m.ProviderRange, + }) + } else { + var configDiags tfdiags.Diagnostics + metaConfigVal, _, configDiags = ctx.EvaluateBlock(m.Config, providerSchema.ProviderMeta.Body, nil, EvalDataForNoInstanceKey) + diags = diags.Append(configDiags) + var deprecationDiags tfdiags.Diagnostics + metaConfigVal, deprecationDiags = ctx.Deprecations().ValidateAndUnmarkConfig(metaConfigVal, providerSchema.ProviderMeta.Body, ctx.Path().Module()) + diags = diags.Append(deprecationDiags.InConfigBody(m.Config, r.addr.String())) + } + } + } + return metaConfigVal, diags +} + // ProviderTransformer is a GraphTransformer that maps resources to providers // within the graph. This will error if there are any resources that don't map // to proper resources. diff --git a/internal/terraform/transform_provider_test.go b/internal/terraform/transform_provider_test.go index 2d7170844223..6cfe20dbbb1b 100644 --- a/internal/terraform/transform_provider_test.go +++ b/internal/terraform/transform_provider_test.go @@ -179,7 +179,7 @@ func TestMissingProviderTransformer_grandchildMissing(t *testing.T) { g := testProviderTransformerGraph(t, mod) { - transform := transformProviders(concrete, mod, nil) + transform := transformProviders(concrete, mod, nil, nil) if err := transform.Transform(g); err != nil { t.Fatalf("err: %s", err) } @@ -244,7 +244,7 @@ func TestProviderConfigTransformer_parentProviders(t *testing.T) { g := testProviderTransformerGraph(t, mod) { - tf := transformProviders(concrete, mod, nil) + tf := transformProviders(concrete, mod, nil, nil) if err := tf.Transform(g); err != nil { t.Fatalf("err: %s", err) } @@ -264,7 +264,7 @@ func TestProviderConfigTransformer_grandparentProviders(t *testing.T) { g := testProviderTransformerGraph(t, mod) { - tf := transformProviders(concrete, mod, nil) + tf := transformProviders(concrete, mod, nil, nil) if err := tf.Transform(g); err != nil { t.Fatalf("err: %s", err) } @@ -298,7 +298,7 @@ resource "test_object" "a" { g := testProviderTransformerGraph(t, mod) { - tf := transformProviders(concrete, mod, nil) + tf := transformProviders(concrete, mod, nil, nil) if err := tf.Transform(g); err != nil { t.Fatalf("err: %s", err) } @@ -376,7 +376,7 @@ resource "test_object" "a" { g := testProviderTransformerGraph(t, mod) { - tf := transformProviders(concrete, mod, nil) + tf := transformProviders(concrete, mod, nil, nil) if err := tf.Transform(g); err != nil { t.Fatalf("err: %s", err) }