diff --git a/datadog/fwprovider/data_source_datadog_api_key.go b/datadog/fwprovider/data_source_datadog_api_key.go index 7d6a2b8344..7772ab44ad 100644 --- a/datadog/fwprovider/data_source_datadog_api_key.go +++ b/datadog/fwprovider/data_source_datadog_api_key.go @@ -44,7 +44,7 @@ func (d *apiKeyDataSource) Metadata(_ context.Context, req datasource.MetadataRe func (d *apiKeyDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ - Description: "Use this data source to retrieve information about an existing api key. Deprecated. This will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use the datadog_api_key resource to manage API keys in your Datadog account.", + Description: "Use this data source to retrieve information about an existing API key. **Deprecated**: This will be removed in a future release with prior notice. For secure access to API key values without storing them in Terraform state, use the ephemeral `datadog_api_key` resource instead. See the ephemeral resource documentation for examples of secure API key access patterns.", Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Description: "Name for API Key.", @@ -59,7 +59,7 @@ func (d *apiKeyDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, Optional: true, }, "key": schema.StringAttribute{ - Description: "The value of the API Key.", + Description: "The value of the API Key. **Security Note**: This field exposes sensitive data in Terraform state. For secure access without state storage, use the ephemeral `datadog_api_key` resource instead.", Computed: true, Sensitive: true, }, @@ -68,7 +68,7 @@ func (d *apiKeyDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, Computed: true, }, }, - DeprecationMessage: "Deprecated. This will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use the datadog_api_key resource to manage API keys in your Datadog account.", + DeprecationMessage: "This data source is deprecated and will be removed in a future release with prior notice. For secure access to API key values without storing them in Terraform state, use the ephemeral datadog_api_key resource instead.", } } @@ -168,7 +168,7 @@ func (r *apiKeyDataSource) updateState(state *apiKeyDataSourceModel, apiKeyData func (r *apiKeyDataSource) checkAPIDeprecated(apiKeyData *datadogV2.FullAPIKey, resp *datasource.ReadResponse) bool { apiKeyAttributes := apiKeyData.GetAttributes() if !apiKeyAttributes.HasKey() { - resp.Diagnostics.AddError("Deprecated", "The datadog_api_key data source is deprecated and will be removed in a future release. Securely store your API key using a secret management system or use the datadog_api_key resource to manage API keys in your Datadog account.") + resp.Diagnostics.AddError("Deprecated", "The datadog_api_key data source is deprecated and will be removed in a future release. For secure access to API key values without storing them in Terraform state, use the ephemeral datadog_api_key resource instead.") return true } return false diff --git a/datadog/fwprovider/ephemeral_resource_datadog_api_key.go b/datadog/fwprovider/ephemeral_resource_datadog_api_key.go new file mode 100644 index 0000000000..bb9873b82b --- /dev/null +++ b/datadog/fwprovider/ephemeral_resource_datadog_api_key.go @@ -0,0 +1,137 @@ +package fwprovider + +import ( + "context" + "log" + + "github.com/DataDog/datadog-api-client-go/v2/api/datadogV2" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Interface assertions for EphemeralAPIKeyResource +var ( + _ ephemeral.EphemeralResource = &EphemeralAPIKeyResource{} + _ ephemeral.EphemeralResourceWithConfigure = &EphemeralAPIKeyResource{} +) + +// EphemeralAPIKeyResource implements ephemeral API key resource +type EphemeralAPIKeyResource struct { + Api *datadogV2.KeyManagementApi + Auth context.Context +} + +// EphemeralAPIKeyModel represents the data model for the ephemeral API key resource +type EphemeralAPIKeyModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Key types.String `tfsdk:"key"` + RemoteConfigReadEnabled types.Bool `tfsdk:"remote_config_read_enabled"` +} + +// NewEphemeralAPIKeyResource creates a new ephemeral API key resource +func NewEphemeralAPIKeyResource() ephemeral.EphemeralResource { + return &EphemeralAPIKeyResource{} +} + +// Metadata implements the core ephemeral.EphemeralResource interface +func (r *EphemeralAPIKeyResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = "api_key" // Will become "datadog_api_key" via wrapper +} + +// Schema implements the core ephemeral.EphemeralResource interface +func (r *EphemeralAPIKeyResource) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Retrieves an existing Datadog API key as an ephemeral resource. The API key value is retrieved securely and made available for use in other resources without being stored in state.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Required: true, + Description: "The ID of the API key to retrieve.", + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "The name of the API key.", + }, + "key": schema.StringAttribute{ + Computed: true, + Sensitive: true, + Description: "The actual API key value (sensitive).", + }, + "remote_config_read_enabled": schema.BoolAttribute{ + Computed: true, + Description: "Whether remote configuration reads are enabled for this key.", + }, + }, + } +} + +// Open implements the core ephemeral.EphemeralResource interface +// This is where the ephemeral resource acquires the API key data +func (r *EphemeralAPIKeyResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + // 1. Extract API key ID from config + var config EphemeralAPIKeyModel + resp.Diagnostics.Append(req.Config.Get(ctx, &config)...) + if resp.Diagnostics.HasError() { + return + } + + // 2. Fetch API key from Datadog API + apiKey, httpResp, err := r.Api.GetAPIKey(r.Auth, config.ID.ValueString()) + if err != nil { + log.Printf("[ERROR] Ephemeral open operation failed for api_key: %v", err) + resp.Diagnostics.AddError( + "API Key Retrieval Failed", + "Unable to fetch API key data from Datadog API", + ) + return + } + + // Check HTTP response status + if httpResp != nil && httpResp.StatusCode >= 400 { + log.Printf("[WARN] Ephemeral open operation failed for api_key") + resp.Diagnostics.AddError( + "API Key Retrieval Failed", + "Received error response from Datadog API", + ) + return + } + + // 3. Extract API key data from response + apiKeyData := apiKey.GetData() + apiKeyAttributes := apiKeyData.GetAttributes() + + // 4. Set result data (including the sensitive key value) + result := EphemeralAPIKeyModel{ + ID: config.ID, + Name: types.StringValue(apiKeyAttributes.GetName()), + Key: types.StringValue(apiKeyAttributes.GetKey()), // SENSITIVE + RemoteConfigReadEnabled: types.BoolValue(apiKeyAttributes.GetRemoteConfigReadEnabled()), + } + + resp.Diagnostics.Append(resp.Result.Set(ctx, &result)...) + if resp.Diagnostics.HasError() { + return + } + + log.Printf("[DEBUG] Ephemeral open operation succeeded for api_key") +} + +// Configure implements the optional ephemeral.EphemeralResourceWithConfigure interface +func (r *EphemeralAPIKeyResource) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + providerData, ok := req.ProviderData.(*FrameworkProvider) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Configure Type", + "Expected *FrameworkProvider", + ) + return + } + + r.Api = providerData.DatadogApiInstances.GetKeyManagementApiV2() + r.Auth = providerData.Auth +} diff --git a/datadog/fwprovider/framework_ephemeral_resource_wrapper.go b/datadog/fwprovider/framework_ephemeral_resource_wrapper.go new file mode 100644 index 0000000000..578a295999 --- /dev/null +++ b/datadog/fwprovider/framework_ephemeral_resource_wrapper.go @@ -0,0 +1,104 @@ +package fwprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + + "github.com/terraform-providers/terraform-provider-datadog/datadog/internal/fwutils" +) + +// Interface assertions for FrameworkEphemeralResourceWrapper +var ( + _ ephemeral.EphemeralResource = &FrameworkEphemeralResourceWrapper{} + _ ephemeral.EphemeralResourceWithConfigure = &FrameworkEphemeralResourceWrapper{} + _ ephemeral.EphemeralResourceWithValidateConfig = &FrameworkEphemeralResourceWrapper{} + _ ephemeral.EphemeralResourceWithConfigValidators = &FrameworkEphemeralResourceWrapper{} + _ ephemeral.EphemeralResourceWithRenew = &FrameworkEphemeralResourceWrapper{} + _ ephemeral.EphemeralResourceWithClose = &FrameworkEphemeralResourceWrapper{} +) + +// NewFrameworkEphemeralResourceWrapper creates a new ephemeral resource wrapper following +// the same pattern as the existing FrameworkResourceWrapper +func NewFrameworkEphemeralResourceWrapper(i *ephemeral.EphemeralResource) ephemeral.EphemeralResource { + return &FrameworkEphemeralResourceWrapper{ + innerResource: i, + } +} + +// FrameworkEphemeralResourceWrapper wraps ephemeral resources to provide consistent behavior +// across all ephemeral resources, following the existing FrameworkResourceWrapper pattern +type FrameworkEphemeralResourceWrapper struct { + innerResource *ephemeral.EphemeralResource +} + +// Metadata implements the core ephemeral.EphemeralResource interface +// Adds provider type name prefix to the resource type name, following existing pattern +func (r *FrameworkEphemeralResourceWrapper) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + (*r.innerResource).Metadata(ctx, req, resp) + resp.TypeName = req.ProviderTypeName + resp.TypeName +} + +// Schema implements the core ephemeral.EphemeralResource interface +// Enriches schema with common framework patterns +func (r *FrameworkEphemeralResourceWrapper) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + (*r.innerResource).Schema(ctx, req, resp) + fwutils.EnrichFrameworkEphemeralResourceSchema(&resp.Schema) +} + +// Open implements the core ephemeral.EphemeralResource interface +// This is where ephemeral resources create/acquire their temporary resources +func (r *FrameworkEphemeralResourceWrapper) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + (*r.innerResource).Open(ctx, req, resp) +} + +// Configure implements the optional ephemeral.EphemeralResourceWithConfigure interface +// Uses interface detection to only call if the inner resource supports configuration +func (r *FrameworkEphemeralResourceWrapper) Configure(ctx context.Context, req ephemeral.ConfigureRequest, resp *ephemeral.ConfigureResponse) { + rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithConfigure) + if ok { + if req.ProviderData == nil { + return + } + _, ok := req.ProviderData.(*FrameworkProvider) + if !ok { + resp.Diagnostics.AddError("Unexpected Ephemeral Resource Configure Type", "") + return + } + + rCasted.Configure(ctx, req, resp) + } +} + +// ValidateConfig implements the optional ephemeral.EphemeralResourceWithValidateConfig interface +// Uses interface detection to only call if the inner resource supports validation +func (r *FrameworkEphemeralResourceWrapper) ValidateConfig(ctx context.Context, req ephemeral.ValidateConfigRequest, resp *ephemeral.ValidateConfigResponse) { + if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithValidateConfig); ok { + rCasted.ValidateConfig(ctx, req, resp) + } +} + +// ConfigValidators implements the optional ephemeral.EphemeralResourceWithConfigValidators interface +// Uses interface detection to only call if the inner resource supports declarative validators +func (r *FrameworkEphemeralResourceWrapper) ConfigValidators(ctx context.Context) []ephemeral.ConfigValidator { + if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithConfigValidators); ok { + return rCasted.ConfigValidators(ctx) + } + return nil +} + +// Renew implements the optional ephemeral.EphemeralResourceWithRenew interface +// Uses interface detection to only call if the inner resource supports renewal +func (r *FrameworkEphemeralResourceWrapper) Renew(ctx context.Context, req ephemeral.RenewRequest, resp *ephemeral.RenewResponse) { + if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithRenew); ok { + rCasted.Renew(ctx, req, resp) + } +} + +// Close implements the optional ephemeral.EphemeralResourceWithClose interface +// Uses interface detection to only call if the inner resource supports cleanup +func (r *FrameworkEphemeralResourceWrapper) Close(ctx context.Context, req ephemeral.CloseRequest, resp *ephemeral.CloseResponse) { + if rCasted, ok := (*r.innerResource).(ephemeral.EphemeralResourceWithClose); ok { + rCasted.Close(ctx, req, resp) + } +} diff --git a/datadog/fwprovider/framework_provider.go b/datadog/fwprovider/framework_provider.go index 42340c308d..78cd1afc5b 100644 --- a/datadog/fwprovider/framework_provider.go +++ b/datadog/fwprovider/framework_provider.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/ephemeral" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -29,6 +30,7 @@ import ( ) var _ provider.Provider = &FrameworkProvider{} +var _ provider.ProviderWithEphemeralResources = &FrameworkProvider{} var Resources = []func() resource.Resource{ NewAgentlessScanningAwsScanOptionsResource, @@ -101,6 +103,10 @@ var Resources = []func() resource.Resource{ NewIncidentNotificationRuleResource, } +var EphemeralResources = []func() ephemeral.EphemeralResource{ + NewEphemeralAPIKeyResource, +} + var Datasources = []func() datasource.DataSource{ NewAPIKeyDataSource, NewApplicationKeyDataSource, @@ -146,6 +152,7 @@ type FrameworkProvider struct { CommunityClient *datadogCommunity.Client DatadogApiInstances *utils.ApiInstances Auth context.Context + StoreSensitiveState bool ConfigureCallbackFunc func(p *FrameworkProvider, request *provider.ConfigureRequest, config *ProviderSchema) diag.Diagnostics Now func() time.Time @@ -170,6 +177,7 @@ type ProviderSchema struct { HttpClientRetryBackoffBase types.Int64 `tfsdk:"http_client_retry_backoff_base"` HttpClientRetryMaxRetries types.Int64 `tfsdk:"http_client_retry_max_retries"` DefaultTags []DefaultTag `tfsdk:"default_tags"` + StoreSensitiveState types.String `tfsdk:"store_sensitive_state"` } type DefaultTag struct { @@ -207,6 +215,18 @@ func (p *FrameworkProvider) DataSources(_ context.Context) []func() datasource.D return wrappedDatasources } +func (p *FrameworkProvider) EphemeralResources(_ context.Context) []func() ephemeral.EphemeralResource { + var wrappedResources []func() ephemeral.EphemeralResource + for _, f := range EphemeralResources { + r := f() + wrappedResources = append(wrappedResources, func() ephemeral.EphemeralResource { + return NewFrameworkEphemeralResourceWrapper(&r) + }) + } + + return wrappedResources +} + func (p *FrameworkProvider) Metadata(_ context.Context, _ provider.MetadataRequest, response *provider.MetadataResponse) { response.TypeName = "datadog_" } @@ -282,6 +302,10 @@ func (p *FrameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, Optional: true, Description: "The HTTP request maximum retry number. Defaults to 3.", }, + "store_sensitive_state": schema.StringAttribute{ + Optional: true, + Description: "Whether to expose API key values in Terraform state. Valid values are [`true`, `false`]. Defaults to `true` for backwards compatibility. When false, API key resources will not include the key value, requiring the use of ephemeral datadog_api_key resources instead.", + }, }, Blocks: map[string]schema.Block{ "default_tags": schema.ListNestedBlock{ @@ -320,9 +344,10 @@ func (p *FrameworkProvider) Configure(ctx context.Context, request provider.Conf return } - // Make config available for data sources and resources + // Make config available for data sources, resources, and ephemeral resources response.DataSourceData = p response.ResourceData = p + response.EphemeralResourceData = p } func (p *FrameworkProvider) ConfigureConfigDefaults(ctx context.Context, config *ProviderSchema) diag.Diagnostics { @@ -421,6 +446,9 @@ func (p *FrameworkProvider) ConfigureConfigDefaults(ctx context.Context, config if config.HttpClientRetryEnabled.IsNull() { config.HttpClientRetryEnabled = types.StringValue("true") } + if config.StoreSensitiveState.IsNull() { + config.StoreSensitiveState = types.StringValue("true") + } // Run validations on the provider config after defaults and values from // env var has been set. @@ -474,6 +502,8 @@ func defaultConfigureFunc(p *FrameworkProvider, request *provider.ConfigureReque diags := diag.Diagnostics{} validate, _ := strconv.ParseBool(config.Validate.ValueString()) httpClientRetryEnabled, _ := strconv.ParseBool(config.HttpClientRetryEnabled.ValueString()) + storeSensitiveState, _ := strconv.ParseBool(config.StoreSensitiveState.ValueString()) + p.StoreSensitiveState = storeSensitiveState cloudProviderType := config.CloudProviderType.ValueString() cloudProviderRegion := config.CloudProviderRegion.ValueString() diff --git a/datadog/fwprovider/resource_datadog_api_key.go b/datadog/fwprovider/resource_datadog_api_key.go index 5f89a0441e..3e2eb7848a 100644 --- a/datadog/fwprovider/resource_datadog_api_key.go +++ b/datadog/fwprovider/resource_datadog_api_key.go @@ -33,14 +33,16 @@ type apiKeyResourceModel struct { } type apiKeyResource struct { - Api *datadogV2.KeyManagementApi - Auth context.Context + Api *datadogV2.KeyManagementApi + Auth context.Context + StoreSensitiveState bool } func (r *apiKeyResource) Configure(_ context.Context, request resource.ConfigureRequest, response *resource.ConfigureResponse) { providerData := request.ProviderData.(*FrameworkProvider) r.Api = providerData.DatadogApiInstances.GetKeyManagementApiV2() r.Auth = providerData.Auth + r.StoreSensitiveState = providerData.StoreSensitiveState } func (r *apiKeyResource) Metadata(_ context.Context, request resource.MetadataRequest, response *resource.MetadataResponse) { @@ -49,14 +51,14 @@ func (r *apiKeyResource) Metadata(_ context.Context, request resource.MetadataRe func (r *apiKeyResource) Schema(_ context.Context, _ resource.SchemaRequest, response *resource.SchemaResponse) { response.Schema = schema.Schema{ - Description: "Provides a Datadog API Key resource. This can be used to create and manage Datadog API Keys. Import functionality for this resource is deprecated and will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use this resource to create and manage new API keys.", + Description: "Provides a Datadog API Key resource. This can be used to create and manage Datadog API Keys. Import functionality for this resource is deprecated and will be removed in a future release with prior notice. For enhanced security when `store_sensitive_state = false`, use the ephemeral `datadog_api_key` resource to access key values without storing them in state.", Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ Description: "Name for API Key.", Required: true, }, "key": schema.StringAttribute{ - Description: "The value of the API Key.", + Description: "The value of the API Key. This field is only populated when the provider's `store_sensitive_state` is set to `true` (default). When `store_sensitive_state` is `false`, use the ephemeral `datadog_api_key` resource to access the key value without storing it in state.", Computed: true, Sensitive: true, PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, @@ -194,8 +196,14 @@ func (r *apiKeyResource) updateState(state *apiKeyResourceModel, apiKeyData *dat d = frameworkDiag.NewErrorDiagnostic("remote_config_read_enabled is true but Remote config is not enabled at org level", "Please either remove remote_config_read_enabled from the resource configuration or enable Remote config at org level") } state.RemoteConfig = types.BoolValue(apiKeyAttributes.GetRemoteConfigReadEnabled()) - if apiKeyAttributes.HasKey() { + if apiKeyAttributes.HasKey() && r.StoreSensitiveState { + // API has key AND user wants to store sensitive state state.Key = types.StringValue(apiKeyAttributes.GetKey()) + } else if !r.StoreSensitiveState { + // User explicitly chose not to store sensitive state - always set to null + state.Key = types.StringNull() } + // If HasKey() is false but StoreSensitiveState is true, leave unchanged + // (PlanModifier will preserve existing state) return d } diff --git a/datadog/internal/fwutils/fw_enrich_schema.go b/datadog/internal/fwutils/fw_enrich_schema.go index 7e4604d05c..85b8e3c56f 100644 --- a/datadog/internal/fwutils/fw_enrich_schema.go +++ b/datadog/internal/fwutils/fw_enrich_schema.go @@ -7,6 +7,7 @@ import ( "strings" datasourceSchema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + ephemeralSchema "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" resourceSchema "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/schema/validator" ) @@ -117,6 +118,58 @@ func enrichDatasourceDescription(r any) datasourceSchema.Attribute { } } +// ============================================================================= +// EPHEMERAL SCHEMA ENRICHMENT FUNCTIONS +// ============================================================================= + +func EnrichFrameworkEphemeralResourceSchema(s *ephemeralSchema.Schema) { + for i, attr := range s.Attributes { + s.Attributes[i] = enrichEphemeralDescription(attr) + } + enrichEphemeralMapBlocks(s.Blocks) +} + +func enrichEphemeralMapBlocks(blocks map[string]ephemeralSchema.Block) { + for _, block := range blocks { + switch v := block.(type) { + case ephemeralSchema.ListNestedBlock: + for i, attr := range v.NestedObject.Attributes { + v.NestedObject.Attributes[i] = enrichEphemeralDescription(attr) + } + enrichEphemeralMapBlocks(v.NestedObject.Blocks) + case ephemeralSchema.SingleNestedBlock: + for i, attr := range v.Attributes { + v.Attributes[i] = enrichEphemeralDescription(attr) + } + enrichEphemeralMapBlocks(v.Blocks) + case ephemeralSchema.SetNestedBlock: + for i, attr := range v.NestedObject.Attributes { + v.NestedObject.Attributes[i] = enrichEphemeralDescription(attr) + } + enrichEphemeralMapBlocks(v.NestedObject.Blocks) + } + } +} + +func enrichEphemeralDescription(r any) ephemeralSchema.Attribute { + switch v := r.(type) { + case ephemeralSchema.StringAttribute: + buildEnrichedSchemaDescription(reflect.ValueOf(&v)) + return v + case ephemeralSchema.Int64Attribute: + buildEnrichedSchemaDescription(reflect.ValueOf(&v)) + return v + case ephemeralSchema.Float64Attribute: + buildEnrichedSchemaDescription(reflect.ValueOf(&v)) + return v + case ephemeralSchema.BoolAttribute: + buildEnrichedSchemaDescription(reflect.ValueOf(&v)) + return v + default: + return r.(ephemeralSchema.Attribute) + } +} + // ============================================================================= // REUSABLE CORE FUNCTIONS (TYPE-AGNOSTIC VIA REFLECTION) // ============================================================================= diff --git a/datadog/provider.go b/datadog/provider.go index 1a645a0a4b..73153a398e 100644 --- a/datadog/provider.go +++ b/datadog/provider.go @@ -204,6 +204,12 @@ func Provider() *schema.Provider { }, }, }, + "store_sensitive_state": { + Type: schema.TypeString, + Optional: true, + Description: "Whether to expose API key values in Terraform state. Valid values are [`true`, `false`]. Defaults to `true` for backwards compatibility. When false, API key resources will not include the key value, requiring the use of ephemeral datadog_api_key resources instead.", + ValidateFunc: validation.StringInSlice([]string{"true", "false"}, true), + }, }, // NEW RESOURCES ARE NOT ALLOWED TO BE ADDED HERE @@ -294,6 +300,7 @@ type ProviderConfiguration struct { DatadogApiInstances *utils.ApiInstances Auth context.Context DefaultTags map[string]interface{} + StoreSensitiveState bool Now func() time.Time } @@ -350,6 +357,11 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{} validate, _ = strconv.ParseBool(v) } + storeSensitiveState := true + if v := d.Get("store_sensitive_state").(string); v != "" { + storeSensitiveState, _ = strconv.ParseBool(v) + } + if validate { if cloudProviderType == "" && (apiKey == "" || appKey == "") { return nil, diag.FromErr(errors.New("api_key and app_key or orgUUID must be set unless validate = false")) @@ -546,6 +558,7 @@ func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{} CommunityClient: communityClient, DatadogApiInstances: apiInstances, Auth: auth, + StoreSensitiveState: storeSensitiveState, Now: time.Now, } diff --git a/datadog/tests/ephemeral_resource_datadog_api_key_test.go b/datadog/tests/ephemeral_resource_datadog_api_key_test.go new file mode 100644 index 0000000000..cc024c2e26 --- /dev/null +++ b/datadog/tests/ephemeral_resource_datadog_api_key_test.go @@ -0,0 +1,60 @@ +package test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/terraform-providers/terraform-provider-datadog/datadog/fwprovider" +) + +// TODO(v6-protocol): once the provider upgrades to protocol v6, add an acceptance +// test using terraform-plugin-testing's echo provider to assert the API key's +// ephemeral value never persists in Terraform state. + +// TestEphemeralAPIKeyResource_Metadata tests the Metadata method +func TestEphemeralAPIKeyResource_Metadata(t *testing.T) { + resource := fwprovider.NewEphemeralAPIKeyResource() + + req := ephemeral.MetadataRequest{ + ProviderTypeName: "datadog", + } + resp := &ephemeral.MetadataResponse{} + + resource.Metadata(context.Background(), req, resp) + + assert.Equal(t, "api_key", resp.TypeName) +} + +// TestEphemeralAPIKeyResource_Schema tests the Schema method +func TestEphemeralAPIKeyResource_Schema(t *testing.T) { + resource := fwprovider.NewEphemeralAPIKeyResource() + + req := ephemeral.SchemaRequest{} + resp := &ephemeral.SchemaResponse{} + + resource.Schema(context.Background(), req, resp) + + // Verify required attributes exist + require.NotNil(t, resp.Schema.Attributes) + + // Check that key is marked as sesnsitive + keyAttr, exists := resp.Schema.Attributes["key"] + require.True(t, exists) + assert.True(t, keyAttr.IsSensitive()) +} + +// TestEphemeralAPIKeyResource_InterfaceAssertion tests interface compliance +func TestEphemeralAPIKeyResource_InterfaceAssertion(t *testing.T) { + resource := fwprovider.NewEphemeralAPIKeyResource() + + // Verify the resource implements required interfaces + _, ok := resource.(ephemeral.EphemeralResource) + assert.True(t, ok, "Resource should implement EphemeralResource") + + _, ok = resource.(ephemeral.EphemeralResourceWithConfigure) + assert.True(t, ok, "Resource should implement EphemeralResourceWithConfigure") +} diff --git a/datadog/tests/framework_ephemeral_resource_wrapper_test.go b/datadog/tests/framework_ephemeral_resource_wrapper_test.go new file mode 100644 index 0000000000..0ef79a0f28 --- /dev/null +++ b/datadog/tests/framework_ephemeral_resource_wrapper_test.go @@ -0,0 +1,121 @@ +package test + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/ephemeral" + "github.com/hashicorp/terraform-plugin-framework/ephemeral/schema" + "github.com/stretchr/testify/assert" + + "github.com/terraform-providers/terraform-provider-datadog/datadog/fwprovider" +) + +// Simple mock for testing wrapper functionality +type mockEphemeralResource struct{} + +func (m *mockEphemeralResource) Metadata(ctx context.Context, req ephemeral.MetadataRequest, resp *ephemeral.MetadataResponse) { + resp.TypeName = "_test_resource" +} + +func (m *mockEphemeralResource) Schema(ctx context.Context, req ephemeral.SchemaRequest, resp *ephemeral.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{Required: true}, + }, + } +} + +func (m *mockEphemeralResource) Open(ctx context.Context, req ephemeral.OpenRequest, resp *ephemeral.OpenResponse) { + // Basic implementation for testing +} + +func TestFrameworkEphemeralResourceWrapper_CoreMethods(t *testing.T) { + t.Parallel() + + var mock ephemeral.EphemeralResource = &mockEphemeralResource{} + wrapped := fwprovider.NewFrameworkEphemeralResourceWrapper(&mock) + + // Test Metadata adds provider prefix + t.Run("Metadata", func(t *testing.T) { + req := ephemeral.MetadataRequest{ProviderTypeName: "datadog"} + resp := &ephemeral.MetadataResponse{} + + wrapped.Metadata(context.Background(), req, resp) + + assert.Equal(t, "datadog_test_resource", resp.TypeName) + }) + + // Test Schema calls enrichment + t.Run("Schema", func(t *testing.T) { + req := ephemeral.SchemaRequest{} + resp := &ephemeral.SchemaResponse{} + + wrapped.Schema(context.Background(), req, resp) + + assert.NotNil(t, resp.Schema) + assert.Contains(t, resp.Schema.Attributes, "id") + }) + + // Test Open delegates properly + t.Run("Open", func(t *testing.T) { + req := ephemeral.OpenRequest{} + resp := &ephemeral.OpenResponse{} + + assert.NotPanics(t, func() { + wrapped.Open(context.Background(), req, resp) + }) + }) +} + +func TestFrameworkEphemeralResourceWrapper_InterfaceDetection(t *testing.T) { + t.Parallel() + + var mock ephemeral.EphemeralResource = &mockEphemeralResource{} + wrappedInterface := fwprovider.NewFrameworkEphemeralResourceWrapper(&mock) + + // Cast to wrapper type to access optional interface methods + wrapped := wrappedInterface.(*fwprovider.FrameworkEphemeralResourceWrapper) + + // Test that optional methods don't panic when inner resource doesn't implement them + t.Run("Configure_NotImplemented", func(t *testing.T) { + req := ephemeral.ConfigureRequest{} + resp := &ephemeral.ConfigureResponse{} + + assert.NotPanics(t, func() { + wrapped.Configure(context.Background(), req, resp) + }) + }) + + t.Run("ValidateConfig_NotImplemented", func(t *testing.T) { + req := ephemeral.ValidateConfigRequest{} + resp := &ephemeral.ValidateConfigResponse{} + + assert.NotPanics(t, func() { + wrapped.ValidateConfig(context.Background(), req, resp) + }) + }) + + t.Run("ConfigValidators_NotImplemented", func(t *testing.T) { + validators := wrapped.ConfigValidators(context.Background()) + assert.Nil(t, validators) + }) + + t.Run("Renew_NotImplemented", func(t *testing.T) { + req := ephemeral.RenewRequest{} + resp := &ephemeral.RenewResponse{} + + assert.NotPanics(t, func() { + wrapped.Renew(context.Background(), req, resp) + }) + }) + + t.Run("Close_NotImplemented", func(t *testing.T) { + req := ephemeral.CloseRequest{} + resp := &ephemeral.CloseResponse{} + + assert.NotPanics(t, func() { + wrapped.Close(context.Background(), req, resp) + }) + }) +} diff --git a/docs/data-sources/api_key.md b/docs/data-sources/api_key.md index 5c5a490303..c1a66437f7 100644 --- a/docs/data-sources/api_key.md +++ b/docs/data-sources/api_key.md @@ -3,12 +3,12 @@ page_title: "datadog_api_key Data Source - terraform-provider-datadog" subcategory: "" description: |- - Use this data source to retrieve information about an existing api key. Deprecated. This will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use the datadog_api_key resource to manage API keys in your Datadog account. + Use this data source to retrieve information about an existing API key. Deprecated: This will be removed in a future release with prior notice. For secure access to API key values without storing them in Terraform state, use the ephemeral datadog_api_key resource instead. See the ephemeral resource documentation for examples of secure API key access patterns. --- # datadog_api_key (Data Source) -Use this data source to retrieve information about an existing api key. Deprecated. This will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use the datadog_api_key resource to manage API keys in your Datadog account. +Use this data source to retrieve information about an existing API key. **Deprecated**: This will be removed in a future release with prior notice. For secure access to API key values without storing them in Terraform state, use the ephemeral `datadog_api_key` resource instead. See the ephemeral resource documentation for examples of secure API key access patterns. ## Example Usage @@ -29,5 +29,5 @@ data "datadog_api_key" "foo" { ### Read-Only -- `key` (String, Sensitive) The value of the API Key. +- `key` (String, Sensitive) The value of the API Key. **Security Note**: This field exposes sensitive data in Terraform state. For secure access without state storage, use the ephemeral `datadog_api_key` resource instead. - `remote_config_read_enabled` (Boolean) Whether the API key is used for remote config. diff --git a/docs/ephemeral-resources/api_key.md b/docs/ephemeral-resources/api_key.md new file mode 100644 index 0000000000..bfeab0d295 --- /dev/null +++ b/docs/ephemeral-resources/api_key.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "datadog_api_key Ephemeral Resource - terraform-provider-datadog" +subcategory: "" +description: |- + Retrieves an existing Datadog API key as an ephemeral resource. The API key value is retrieved securely and made available for use in other resources without being stored in state. +--- + +# datadog_api_key (Ephemeral Resource) + +Retrieves an existing Datadog API key as an ephemeral resource. The API key value is retrieved securely and made available for use in other resources without being stored in state. + +## Example Usage + +```terraform +# Example: Using ephemeral resources for enhanced security +# Set store_sensitive_state = false in your provider configuration + +terraform { + required_providers { + datadog = { + source = "DataDog/datadog" + } + } +} + +provider "datadog" { + # Enhanced security: API key values won't be stored in state + store_sensitive_state = false +} + +# Create the API key resource (key value won't be stored in state) +resource "datadog_api_key" "example" { + name = "Example API Key" +} + +# Access the key value using ephemeral resource (not stored in state) +ephemeral "datadog_api_key" "example" { + id = datadog_api_key.example.id +} + +# Use the ephemeral key value in other resources +resource "some_external_resource" "example" { + api_key = ephemeral.datadog_api_key.example.key +} + +# Or store in locals for reuse +locals { + api_key = ephemeral.datadog_api_key.example.key +} +``` + + +## Schema + +### Required + +- `id` (String) The ID of the API key to retrieve. + +### Read-Only + +- `key` (String, Sensitive) The actual API key value (sensitive). +- `name` (String) The name of the API key. +- `remote_config_read_enabled` (Boolean) Whether remote configuration reads are enabled for this key. diff --git a/docs/index.md b/docs/index.md index d8bca80d46..6ccb8332b7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,6 +64,7 @@ provider "datadog" { - `http_client_retry_max_retries` (Number) The HTTP request maximum retry number. Defaults to 3. - `http_client_retry_timeout` (Number) The HTTP request retry timeout period. Defaults to 60 seconds. - `org_uuid` (String) The organization UUID; used for cloud-provider-based authentication. See the [Datadog API documentation](https://docs.datadoghq.com/api/v1/organizations/) for more information. +- `store_sensitive_state` (String) Whether to expose API key values in Terraform state. Valid values are [`true`, `false`]. Defaults to `true` for backwards compatibility. When false, API key resources will not include the key value, requiring the use of ephemeral datadog_api_key resources instead. - `validate` (String) Enables validation of the provided API key during provider initialization. Valid values are [`true`, `false`]. Default is true. When false, api_key won't be checked. diff --git a/docs/resources/api_key.md b/docs/resources/api_key.md index b2dd7dc757..44a4d12692 100644 --- a/docs/resources/api_key.md +++ b/docs/resources/api_key.md @@ -3,12 +3,12 @@ page_title: "datadog_api_key Resource - terraform-provider-datadog" subcategory: "" description: |- - Provides a Datadog API Key resource. This can be used to create and manage Datadog API Keys. Import functionality for this resource is deprecated and will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use this resource to create and manage new API keys. + Provides a Datadog API Key resource. This can be used to create and manage Datadog API Keys. Import functionality for this resource is deprecated and will be removed in a future release with prior notice. For enhanced security when store_sensitive_state = false, use the ephemeral datadog_api_key resource to access key values without storing them in state. --- # datadog_api_key (Resource) -Provides a Datadog API Key resource. This can be used to create and manage Datadog API Keys. Import functionality for this resource is deprecated and will be removed in a future release with prior notice. Securely store your API keys using a secret management system or use this resource to create and manage new API keys. +Provides a Datadog API Key resource. This can be used to create and manage Datadog API Keys. Import functionality for this resource is deprecated and will be removed in a future release with prior notice. For enhanced security when `store_sensitive_state = false`, use the ephemeral `datadog_api_key` resource to access key values without storing them in state. ## Example Usage @@ -33,7 +33,7 @@ resource "datadog_api_key" "foo" { ### Read-Only - `id` (String) The ID of this resource. -- `key` (String, Sensitive) The value of the API Key. +- `key` (String, Sensitive) The value of the API Key. This field is only populated when the provider's `store_sensitive_state` is set to `true` (default). When `store_sensitive_state` is `false`, use the ephemeral `datadog_api_key` resource to access the key value without storing it in state. ## Import diff --git a/examples/ephemeral-resources/datadog_api_key/ephemeral-resource.tf b/examples/ephemeral-resources/datadog_api_key/ephemeral-resource.tf new file mode 100644 index 0000000000..054dc067ff --- /dev/null +++ b/examples/ephemeral-resources/datadog_api_key/ephemeral-resource.tf @@ -0,0 +1,35 @@ +# Example: Using ephemeral resources for enhanced security +# Set store_sensitive_state = false in your provider configuration + +terraform { + required_providers { + datadog = { + source = "DataDog/datadog" + } + } +} + +provider "datadog" { + # Enhanced security: API key values won't be stored in state + store_sensitive_state = false +} + +# Create the API key resource (key value won't be stored in state) +resource "datadog_api_key" "example" { + name = "Example API Key" +} + +# Access the key value using ephemeral resource (not stored in state) +ephemeral "datadog_api_key" "example" { + id = datadog_api_key.example.id +} + +# Use the ephemeral key value in other resources +resource "some_external_resource" "example" { + api_key = ephemeral.datadog_api_key.example.key +} + +# Or store in locals for reuse +locals { + api_key = ephemeral.datadog_api_key.example.key +} \ No newline at end of file diff --git a/go.mod b/go.mod index 0e87b6c813..14669965b6 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/hashicorp/terraform-plugin-sdk/v2 v2.37.0 github.com/hashicorp/terraform-plugin-testing v1.12.0 github.com/jonboulle/clockwork v0.2.2 + github.com/stretchr/testify v1.8.3 github.com/zorkian/go-datadog-api v2.30.0+incompatible gopkg.in/DataDog/dd-trace-go.v1 v1.34.0 gopkg.in/dnaeon/go-vcr.v3 v3.1.2 @@ -40,6 +41,7 @@ require ( github.com/bgentry/speakeasy v0.1.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -75,6 +77,7 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/philhofer/fwd v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/russross/blackfriday v1.6.0 // indirect diff --git a/go.sum b/go.sum index 20a105c794..50497bbbd1 100644 --- a/go.sum +++ b/go.sum @@ -255,8 +255,9 @@ github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQ github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=