| // Copyright IBM Corp. 2014, 2026 | |
| // SPDX-License-Identifier: BUSL-1.1 | |
| package policy | |
| import ( | |
| "context" | |
| "fmt" | |
| "log" | |
| "os" | |
| "os/exec" | |
| "time" | |
| "github.com/apparentlymart/go-versions/versions" | |
| "github.com/apparentlymart/go-versions/versions/constraints" | |
| "github.com/hashicorp/go-hclog" | |
| "github.com/hashicorp/go-plugin" | |
| "github.com/zclconf/go-cty/cty" | |
| "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" | |
| "go.opentelemetry.io/otel/attribute" | |
| "go.opentelemetry.io/otel/codes" | |
| "go.opentelemetry.io/otel/trace" | |
| "google.golang.org/grpc" | |
| "github.com/hashicorp/terraform/internal/policy/callback" | |
| "github.com/hashicorp/terraform/internal/policy/proto" | |
| "github.com/hashicorp/terraform/version" | |
| ) | |
| const ( | |
| TerraformPolicyPluginEnvVar = "TF_POLICY_PLUGIN" | |
| TerraformPolicyLogLevelEnvVar = "TF_POLICY_LOG_LEVEL" | |
| callbackServiceTimeout = 10 * time.Second | |
| ) | |
| var _ CallbackService = (*client)(nil) | |
| var _ Client = (*client)(nil) | |
| // NewPolicyClient initializes and connects to a new tfpolicy-plugin process | |
| func NewPolicyClient(ctx context.Context, policyPluginPath string, policyPaths []string, ent *Entitlement) (Client, Diagnostics) { | |
| var diags Diagnostics | |
| client, err := Connect(ctx, policyPluginPath) | |
| if err != nil { | |
| diags = append(diags, NewErrorDiagnostic( | |
| "Failed to connect to policy engine", | |
| fmt.Sprintf("Failed to connect to policy engine: %s.", err), | |
| SetupErrorResult, | |
| )) | |
| return nil, diags | |
| } | |
| var callbackServiceID uint32 | |
| // initialize the callback service if the client supports it | |
| if srv, ok := client.(CallbackService); ok { | |
| callbackServer, cbDiags := srv.RegisterCallbackService(ctx) | |
| if cbDiags != nil { | |
| client.Stop() | |
| return nil, cbDiags | |
| } | |
| callbackServiceID = callbackServer.ID | |
| } | |
| resp := client.Setup(ctx, SetupRequest{ | |
| SourceLocations: policyPaths, | |
| CallbackService: callbackServiceID, | |
| Entitlement: ent, | |
| }) | |
| diags = append(diags, resp.Diagnostics...) | |
| if diags.HasErrors() { | |
| client.Stop() | |
| return nil, diags | |
| } | |
| var requiredVersions constraints.IntersectionSpec | |
| for _, config := range resp.ServerConfigurations() { | |
| version, err := constraints.ParseRubyStyleMulti(config.RequiredVersion) | |
| if err != nil { | |
| diags = append(diags, NewErrorDiagnostic( | |
| "Failed to validate required Terraform version", | |
| fmt.Sprintf("The policy file %s had a Terraform version constraint that could not be parsed: %s.", config.File, err), | |
| SetupErrorResult, | |
| )) | |
| continue | |
| } | |
| requiredVersions = append(requiredVersions, version...) | |
| } | |
| if diags.HasErrors() { | |
| client.Stop() | |
| return nil, diags | |
| } | |
| terraformVersion, err := versions.ParseVersion(version.Version) | |
| if err != nil { | |
| client.Stop() | |
| // This is crazy, it means the internal version number is invalid. | |
| panic(err) | |
| } | |
| constraint := versions.MeetingConstraints(requiredVersions) | |
| if !constraint.Has(terraformVersion) { | |
| diags = append(diags, NewErrorDiagnostic( | |
| "Invalid Terraform version for policies", | |
| fmt.Sprintf("The current version of Terraform is %s, and it is not compatible with the versions of Terraform required by the selected policies.", version.String()), | |
| SetupErrorResult, | |
| )) | |
| client.Stop() | |
| return nil, diags | |
| } | |
| return client, diags | |
| } | |
| // Connect creates a connection to tfpolicy-plugin. If policyPluginPath is empty, the command lookup | |
| // will default to the executable "tfpolicy-plugin" in the $PATH. | |
| func Connect(ctx context.Context, policyPluginPath string) (Client, error) { | |
| ctx, span := tracer().Start(ctx, "policy.client.connect") | |
| defer span.End() | |
| pgm := "tfpolicy-plugin" // by default, just use this if it's in the path | |
| if policyPluginPath != "" { | |
| pgm = policyPluginPath | |
| } | |
| cmd := exec.CommandContext(ctx, pgm, "rpcapi") | |
| plugin := plugin.NewClient(&plugin.ClientConfig{ | |
| HandshakeConfig: plugin.HandshakeConfig{ | |
| ProtocolVersion: 1, | |
| MagicCookieKey: "TF_POLICY_PLUGIN", | |
| MagicCookieValue: "6F11ED78A2AB", | |
| }, | |
| Plugins: map[string]plugin.Plugin{ | |
| "policy": new(policy), | |
| }, | |
| Cmd: cmd, | |
| AllowedProtocols: []plugin.Protocol{ | |
| plugin.ProtocolGRPC, | |
| }, | |
| // This propagates the active trace context via gRPC metadata so the plugin's handler | |
| // spans become children of the terraform-side span that invoked them. | |
| GRPCDialOptions: []grpc.DialOption{ | |
| grpc.WithStatsHandler(otelgrpc.NewClientHandler()), | |
| }, | |
| Logger: hclog.New(&hclog.LoggerOptions{ | |
| Level: func() hclog.Level { | |
| level := hclog.LevelFromString(os.Getenv(TerraformPolicyLogLevelEnvVar)) | |
| if level == hclog.NoLevel { | |
| return hclog.Error | |
| } | |
| return level | |
| }(), | |
| }), | |
| }) | |
| rpc, err := plugin.Client() | |
| if err != nil { | |
| plugin.Kill() | |
| err = fmt.Errorf("failed to connect to plugin: %v", err) | |
| span.RecordError(err) | |
| span.SetStatus(codes.Error, err.Error()) | |
| return nil, err | |
| } | |
| raw, err := rpc.Dispense("policy") | |
| if err != nil { | |
| plugin.Kill() | |
| err = fmt.Errorf("failed to dispense plugin: %v", err) | |
| span.RecordError(err) | |
| span.SetStatus(codes.Error, err.Error()) | |
| return nil, err | |
| } | |
| sc := raw.(*client) | |
| sc.plugin = plugin | |
| return sc, nil | |
| } | |
| type client struct { | |
| plugin *plugin.Client | |
| broker *plugin.GRPCBroker | |
| client proto.PolicyClient | |
| callbackRegistry callback.Registry | |
| cbServer *callback.Server | |
| } | |
| func (c *client) RegisterCallbackService(ctx context.Context) (*callback.Server, Diagnostics) { | |
| if c.cbServer != nil { | |
| panic("callback service already registered") | |
| } | |
| cbServiceID := c.broker.NextId() | |
| c.cbServer = &callback.Server{ | |
| ID: cbServiceID, | |
| Registry: c.callbackRegistry, | |
| } | |
| serverCh := make(chan *grpc.Server, 1) | |
| // Start the callback service server on the broker. | |
| go c.broker.AcceptAndServe(cbServiceID, func(opts []grpc.ServerOption) *grpc.Server { | |
| // Install the OpenTelemetry gRPC server stats handler so that the | |
| // trace context the plugin client injects into outgoing callback RPC | |
| // metadata is extracted and made the parent of the callback handler | |
| // spans. | |
| opts = append(opts, grpc.StatsHandler(otelgrpc.NewServerHandler())) | |
| server := grpc.NewServer(opts...) | |
| proto.RegisterCallbackServiceServer(server, c.cbServer) | |
| serverCh <- server | |
| return server | |
| }) | |
| select { | |
| // Wait for the server to be ready before returning. | |
| case server := <-serverCh: | |
| c.cbServer.Grpc = server | |
| // If the context is done, return early with an error. | |
| case <-ctx.Done(): | |
| return nil, Diagnostics{ | |
| NewErrorDiagnostic("Failed to register callback service", | |
| fmt.Sprintf("Failed to register callback service: %v.", ctx.Err()), | |
| SetupErrorResult, | |
| ), | |
| } | |
| // If the wait has exceeded the timeout, return early with an error. | |
| case <-time.After(callbackServiceTimeout): | |
| return nil, Diagnostics{ | |
| NewErrorDiagnostic("Failed to register callback service", | |
| fmt.Sprintf("Failed to register callback service: timed out after %s.", callbackServiceTimeout), | |
| SetupErrorResult, | |
| ), | |
| } | |
| } | |
| return c.cbServer, nil | |
| } | |
| func (c *client) Setup(ctx context.Context, req SetupRequest) SetupResponse { | |
| ctx, span := tracer().Start(ctx, "policy.client.setup") | |
| defer span.End() | |
| log.Printf("[DEBUG] Setting up Terraform Policy connection") | |
| protoReq := &proto.PolicySetupRequest{ | |
| ClientCapabilities: new(proto.PolicySetupRequest_ClientCapabilities), | |
| SourceLocations: req.SourceLocations, | |
| CallbackService: req.CallbackService, | |
| } | |
| if req.Entitlement != nil { | |
| protoReq.Entitlement = &proto.PolicySetupRequest_Entitlement{ | |
| Host: req.Entitlement.Host, | |
| Token: req.Entitlement.Token, | |
| Org: req.Entitlement.Org, | |
| } | |
| } | |
| response, err := c.client.Setup(ctx, protoReq) | |
| if err != nil { | |
| return SetupResponse{Diagnostics: Diagnostics{ | |
| NewErrorDiagnostic("Failed to setup Terraform Policy connection", | |
| fmt.Sprintf("Failed to setup Terraform Policy connection: %v.", err), | |
| SetupErrorResult, | |
| ), | |
| }} | |
| } | |
| return SetupResponse{ | |
| serverCapabilities: response.ServerCapabilities, | |
| Diagnostics: DiagsFromProto(response.Diagnostics, nil), | |
| } | |
| } | |
| func (c *client) EvaluateResource(ctx context.Context, req EvaluationRequest[*proto.PolicyEvaluateResourceRequest_ResourceMetadata]) EvaluationResponse { | |
| ctx, span := tracer().Start(ctx, "policy.client.evaluate_resource", | |
| trace.WithAttributes(attribute.String("policy.resource.type", req.Target)), | |
| ) | |
| defer span.End() | |
| log.Printf("[DEBUG] Evaluating policy for resource %s", req.Target) | |
| var diags []*proto.Diagnostic | |
| req = normalizeRequest(req) | |
| attrs, err := resourceAttributesToProto(req.Attrs) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to serialize attributes", | |
| Detail: fmt.Sprintf("Failed to serialize attributes: %v.", err), | |
| })) | |
| } | |
| priorAttrs, err := resourceAttributesToProto(req.PriorAttrs) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to serialize prior attributes", | |
| Detail: fmt.Sprintf("Failed to serialize prior attributes: %v.", err), | |
| })) | |
| } | |
| evalID := c.callbackRegistry.NextID() | |
| request := &proto.PolicyEvaluateResourceRequest{ | |
| EvaluationId: evalID, | |
| Resource: req.Target, | |
| Attrs: attrs, | |
| PriorAttrs: priorAttrs, | |
| Metadata: req.Meta, | |
| } | |
| // Register the callback functions with the callback service, so that they are available | |
| // for use during evaluation. | |
| c.callbackRegistry.Register(evalID, req.Callbacks) | |
| // We can unregister the callback functions after the evaluation is complete. | |
| defer c.callbackRegistry.Unregister(evalID) | |
| response, err := c.client.EvaluateResource(ctx, request) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to evaluate Terraform Policy", | |
| Detail: fmt.Sprintf("Failed to evaluate Terraform Policy: %v.", err), | |
| })) | |
| } | |
| return EvaluationFromProtoResponse(response.Result, response.PolicyDetails) | |
| } | |
| func (c *client) EvaluateProvider(ctx context.Context, req EvaluationRequest[*proto.PolicyEvaluateProviderRequest_ProviderMetadata]) EvaluationResponse { | |
| ctx, span := tracer().Start(ctx, "policy.client.evaluate_provider", | |
| trace.WithAttributes(attribute.String("policy.provider.type", req.Target)), | |
| ) | |
| defer span.End() | |
| log.Printf("[DEBUG] Evaluating policy for provider %s", req.Target) | |
| var diags []*proto.Diagnostic | |
| req = normalizeRequest(req) | |
| attrs, err := resourceAttributesToProto(req.Attrs) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to serialize attributes", | |
| Detail: fmt.Sprintf("Failed to serialize attributes: %v.", err), | |
| })) | |
| } | |
| request := &proto.PolicyEvaluateProviderRequest{ | |
| ProviderType: req.Target, | |
| Attrs: attrs, | |
| Metadata: req.Meta, | |
| } | |
| response, err := c.client.EvaluateProvider(ctx, request) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to evaluate Terraform Policy", | |
| Detail: fmt.Sprintf("Failed to evaluate Terraform Policy: %v.", err), | |
| })) | |
| } | |
| return EvaluationFromProtoResponse(response.Result, response.PolicyDetails) | |
| } | |
| func (c *client) EvaluateModule(ctx context.Context, req EvaluationRequest[*proto.PolicyEvaluateModuleRequest_ModuleMetadata]) EvaluationResponse { | |
| ctx, span := tracer().Start(ctx, "policy.client.evaluate_module", | |
| trace.WithAttributes(attribute.String("policy.module.source", req.Target)), | |
| ) | |
| defer span.End() | |
| log.Printf("[DEBUG] Evaluating policy for module %s", req.Target) | |
| var diags []*proto.Diagnostic | |
| req = normalizeRequest(req) | |
| attrs, err := resourceAttributesToProto(req.Attrs) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to serialize attributes", | |
| Detail: fmt.Sprintf("Failed to serialize attributes: %v.", err), | |
| })) | |
| } | |
| request := &proto.PolicyEvaluateModuleRequest{ | |
| ModuleSource: req.Target, | |
| Attrs: attrs, | |
| Metadata: req.Meta, | |
| } | |
| response, err := c.client.EvaluateModule(ctx, request) | |
| if err != nil { | |
| return ErrorEvalFromDiags(append(diags, &proto.Diagnostic{ | |
| Severity: proto.Severity_ERROR, | |
| Summary: "Failed to evaluate Terraform Policy", | |
| Detail: fmt.Sprintf("Failed to evaluate Terraform Policy: %v.", err), | |
| })) | |
| } | |
| return EvaluationFromProtoResponse(response.Result, response.PolicyDetails) | |
| } | |
| func (c *client) Stop() { | |
| log.Println("[DEBUG] stopping policy client") | |
| if c.cbServer != nil { | |
| c.cbServer.Stop() | |
| } | |
| c.plugin.Kill() | |
| } | |
| func normalizeRequest[T any](req EvaluationRequest[T]) EvaluationRequest[T] { | |
| attrs := req.Attrs | |
| priorAttrs := req.PriorAttrs | |
| if attrs.Raw == cty.NilVal { | |
| attrs.Raw = cty.EmptyObjectVal | |
| } | |
| if priorAttrs.Raw == cty.NilVal { | |
| priorAttrs.Raw = cty.EmptyObjectVal | |
| } | |
| return EvaluationRequest[T]{ | |
| Target: req.Target, | |
| Attrs: attrs, | |
| PriorAttrs: priorAttrs, | |
| Meta: req.Meta, | |
| Callbacks: req.Callbacks, | |
| } | |
| } | |
| type policy struct { | |
| plugin.NetRPCUnsupportedPlugin | |
| } | |
| func (s *policy) GRPCServer(*plugin.GRPCBroker, *grpc.Server) error { | |
| // This package is only implementing the client side of the Terraform Policy | |
| // plugin. | |
| return fmt.Errorf("server configuration not supported") | |
| } | |
| func (s *policy) GRPCClient(_ context.Context, broker *plugin.GRPCBroker, conn *grpc.ClientConn) (interface{}, error) { | |
| return &client{ | |
| plugin: nil, // this will be set by the Connect function | |
| broker: broker, | |
| client: proto.NewPolicyClient(conn), | |
| callbackRegistry: callback.NewRegistry(), | |
| }, nil | |
| } |