From 6962d1ba6183a4cfb59f80452737a1b3371cf666 Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:40:09 +0100 Subject: [PATCH 1/5] Update test for job --- pkg/sdkv2/resources/job_acceptance_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sdkv2/resources/job_acceptance_test.go b/pkg/sdkv2/resources/job_acceptance_test.go index 14b3b06..27b2285 100644 --- a/pkg/sdkv2/resources/job_acceptance_test.go +++ b/pkg/sdkv2/resources/job_acceptance_test.go @@ -616,7 +616,7 @@ resource "dbtcloud_job" "test_job" { project_id = dbtcloud_project.test_job_project.id environment_id = dbtcloud_environment.test_job_environment.environment_id execute_steps = [ - "dbt test" + "dbt run" ] triggers = { "github_webhook": %s, From c72217171524e5dd038aff00819b5bab4d7da912 Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:40:49 +0100 Subject: [PATCH 2/5] Update doc for environment --- docs/resources/environment.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/resources/environment.md b/docs/resources/environment.md index 0a38abb..572e5c4 100644 --- a/docs/resources/environment.md +++ b/docs/resources/environment.md @@ -64,10 +64,10 @@ resource "dbtcloud_environment" "dev_environment" { ### Optional - `connection_id` (Number) The ID of the connection to use (can be the `id` of a `dbtcloud_global_connection` or the `connection_id` of a legacy connection). -- At the moment, it is optional and the environment will use the connection set in `dbtcloud_project_connection` if `connection_id` is not set in this resource -- In future versions this field will become required, so it is recommended to set it from now on -- When configuring this field, it needs to be configured for all the environments of the project -- To avoid Terraform state issues, when using this field, the `dbtcloud_project_connection` resource should be removed from the project or you need to make sure that the `connection_id` is the same in `dbtcloud_project_connection` and in the `connection_id` of the Development environment of the project + - At the moment, it is optional and the environment will use the connection set in `dbtcloud_project_connection` if `connection_id` is not set in this resource + - In future versions this field will become required, so it is recommended to set it from now on + - When configuring this field, it needs to be configured for all the environments of the project + - To avoid Terraform state issues, when using this field, the `dbtcloud_project_connection` resource should be removed from the project or you need to make sure that the `connection_id` is the same in `dbtcloud_project_connection` and in the `connection_id` of the Development environment of the project - `credential_id` (Number) Credential ID to create the environment with. A credential is not required for development environments but is required for deployment environments - `custom_branch` (String) Which custom branch to use in this environment - `dbt_version` (String) Version number of dbt to use in this environment. It needs to be in the format `major.minor.0-latest` (e.g. `1.5.0-latest`), `major.minor.0-pre` or `versionless`. Defaults to`versionless` if no version is provided From 525141cf631c09a0b129ee68097cf6ad147dd59a Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:41:55 +0100 Subject: [PATCH 3/5] Allow setting external oauth id for Snowflake --- docs/resources/global_connection.md | 2 +- pkg/dbt_cloud/global_connection.go | 2 +- .../objects/global_connection/common.go | 60 +++++++++++++++---- .../objects/global_connection/resource.go | 30 ++++------ .../resource_acceptance_test.go | 30 ++++++++++ .../objects/global_connection/schema.go | 3 +- 6 files changed, 94 insertions(+), 33 deletions(-) diff --git a/docs/resources/global_connection.md b/docs/resources/global_connection.md index 9e6b419..22e68bd 100644 --- a/docs/resources/global_connection.md +++ b/docs/resources/global_connection.md @@ -157,6 +157,7 @@ resource "dbtcloud_global_connection" "synapse" { - `bigquery` (Attributes) (see [below for nested schema](#nestedatt--bigquery)) - `databricks` (Attributes) Databricks connection configuration (see [below for nested schema](#nestedatt--databricks)) - `fabric` (Attributes) Microsoft Fabric connection configuration. (see [below for nested schema](#nestedatt--fabric)) +- `oauth_configuration_id` (Number) External OAuth configuration ID (only Snowflake for now) - `postgres` (Attributes) PostgreSQL connection configuration. (see [below for nested schema](#nestedatt--postgres)) - `private_link_endpoint_id` (String) Private Link Endpoint ID. This ID can be found using the `privatelink_endpoint` data source - `redshift` (Attributes) Redshift connection configuration (see [below for nested schema](#nestedatt--redshift)) @@ -169,7 +170,6 @@ resource "dbtcloud_global_connection" "synapse" { - `adapter_version` (String) Version of the adapter - `id` (Number) Connection Identifier - `is_ssh_tunnel_enabled` (Boolean) Whether the connection can use an SSH tunnel -- `oauth_configuration_id` (Number) ### Nested Schema for `apache_spark` diff --git a/pkg/dbt_cloud/global_connection.go b/pkg/dbt_cloud/global_connection.go index 406c9ed..2622edf 100644 --- a/pkg/dbt_cloud/global_connection.go +++ b/pkg/dbt_cloud/global_connection.go @@ -57,7 +57,7 @@ type GlobalConnectionCommon struct { Name *string `json:"name,omitempty"` IsSshTunnelEnabled *bool `json:"is_ssh_tunnel_enabled,omitempty"` PrivateLinkEndpointId nullable.Nullable[string] `json:"private_link_endpoint_id,omitempty"` - OauthConfigurationId *int64 `json:"oauth_configuration_id,omitempty"` + OauthConfigurationId nullable.Nullable[int64] `json:"oauth_configuration_id,omitempty"` // OauthRedirectUri *string `json:"oauth_redirect_uri"` //those are read-only fields, we could maybe get them as Computed but never send them // IsConfiguredForNativeOauth bool `json:"is_configured_for_native_oauth"` } diff --git a/pkg/framework/objects/global_connection/common.go b/pkg/framework/objects/global_connection/common.go index 1f4ee78..e443850 100644 --- a/pkg/framework/objects/global_connection/common.go +++ b/pkg/framework/objects/global_connection/common.go @@ -38,7 +38,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(snowflakeCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -46,6 +45,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // snowflake settings state.SnowflakeConfig.Account = types.StringPointerValue(snowflakeCfg.Account) @@ -89,7 +93,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(bigqueryCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -97,6 +100,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // BigQuery settings state.BigQueryConfig.GCPProjectID = types.StringPointerValue(bigqueryCfg.ProjectID) @@ -214,7 +222,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(databricksCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -222,6 +229,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Databricks settings state.DatabricksConfig.Host = types.StringPointerValue(databricksCfg.Host) @@ -263,7 +275,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(redshiftCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -271,6 +282,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Redshift settings state.RedshiftConfig.HostName = types.StringPointerValue(redshiftCfg.HostName) @@ -324,7 +340,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(postgresCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -332,6 +347,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Postgres settings state.PostgresConfig.HostName = types.StringPointerValue(postgresCfg.HostName) @@ -380,7 +400,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(fabricCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -388,6 +407,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Fabric settings state.FabricConfig.Server = types.StringPointerValue(fabricCfg.Server) @@ -421,7 +445,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(synapseCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -429,6 +452,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Synapse settings state.SynapseConfig.Host = types.StringPointerValue(synapseCfg.Host) @@ -462,7 +490,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(starburstCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -470,6 +497,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Starburst settings state.StarburstConfig.Method = types.StringPointerValue(starburstCfg.Method) @@ -500,7 +532,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(athenaCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -508,6 +539,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Athena settings state.AthenaConfig.RegionName = types.StringPointerValue(athenaCfg.RegionName) @@ -591,7 +627,6 @@ func readGeneric( state.AdapterVersion = types.StringValue(sparkCfg.AdapterVersion()) state.Name = types.StringPointerValue(common.Name) state.IsSshTunnelEnabled = types.BoolPointerValue(common.IsSshTunnelEnabled) - state.OauthConfigurationId = types.Int64PointerValue(common.OauthConfigurationId) // nullable common fields if !common.PrivateLinkEndpointId.IsNull() { @@ -599,6 +634,11 @@ func readGeneric( } else { state.PrivateLinkEndpointId = types.StringNull() } + if !common.OauthConfigurationId.IsNull() { + state.OauthConfigurationId = types.Int64Value(common.OauthConfigurationId.MustGet()) + } else { + state.OauthConfigurationId = types.Int64Null() + } // Spark settings state.ApacheSparkConfig.Method = types.StringPointerValue(sparkCfg.Method) diff --git a/pkg/framework/objects/global_connection/resource.go b/pkg/framework/objects/global_connection/resource.go index ab9fd48..24b9246 100644 --- a/pkg/framework/objects/global_connection/resource.go +++ b/pkg/framework/objects/global_connection/resource.go @@ -141,6 +141,9 @@ func (r *globalConnectionResource) Create( if !plan.PrivateLinkEndpointId.IsNull() { commonCfg.PrivateLinkEndpointId.Set(plan.PrivateLinkEndpointId.ValueString()) } + if !plan.OauthConfigurationId.IsNull() { + commonCfg.OauthConfigurationId.Set(plan.OauthConfigurationId.ValueInt64()) + } // data warehouse specific switch { @@ -173,7 +176,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(snowflakeCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.BigQueryConfig != nil: @@ -253,7 +255,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(bigqueryCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.DatabricksConfig != nil: @@ -286,7 +287,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(databricksCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.RedshiftConfig != nil: @@ -337,7 +337,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(redshiftCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.PostgresConfig != nil: @@ -391,7 +390,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(postgresCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.FabricConfig != nil: @@ -421,7 +419,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(fabricCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.SynapseConfig != nil: @@ -451,7 +448,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(synapseCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.StarburstConfig != nil: @@ -477,7 +473,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(starburstCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.AthenaConfig != nil: @@ -529,7 +524,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(athenaCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) case plan.ApacheSparkConfig != nil: @@ -573,7 +567,6 @@ func (r *globalConnectionResource) Create( // we set the computed values that don't have any default plan.ID = types.Int64PointerValue(commonResp.ID) plan.AdapterVersion = types.StringValue(sparkCfg.AdapterVersion()) - plan.OauthConfigurationId = types.Int64PointerValue(commonResp.OauthConfigurationId) plan.IsSshTunnelEnabled = types.BoolPointerValue(commonResp.IsSshTunnelEnabled) default: @@ -659,6 +652,13 @@ func (r *globalConnectionResource) Update( globalConfigChanges.PrivateLinkEndpointId.Set(plan.PrivateLinkEndpointId.ValueString()) } } + if plan.OauthConfigurationId != state.OauthConfigurationId { + if plan.OauthConfigurationId.IsNull() { + globalConfigChanges.OauthConfigurationId.SetNull() + } else { + globalConfigChanges.OauthConfigurationId.Set(plan.OauthConfigurationId.ValueInt64()) + } + } switch { case plan.SnowflakeConfig != nil: @@ -712,7 +712,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.BigQueryConfig != nil: @@ -869,7 +868,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.DatabricksConfig != nil: @@ -922,7 +920,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.RedshiftConfig != nil: @@ -965,7 +962,6 @@ func (r *globalConnectionResource) Update( } // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) } else { // if the warehouseConfig didn't change, we keep the existing state values @@ -1027,7 +1023,6 @@ func (r *globalConnectionResource) Update( } // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) } else { // if the warehouseConfig didn't change, we keep the existing state values @@ -1090,7 +1085,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.SynapseConfig != nil: @@ -1134,7 +1128,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.StarburstConfig != nil: @@ -1169,7 +1162,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.AthenaConfig != nil: @@ -1267,7 +1259,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) case plan.ApacheSparkConfig != nil: @@ -1332,7 +1323,6 @@ func (r *globalConnectionResource) Update( // we set the computed values, no need to do it for ID as we use a PlanModifier with UseStateForUnknown() plan.IsSshTunnelEnabled = types.BoolPointerValue(updateCommon.IsSshTunnelEnabled) - plan.OauthConfigurationId = types.Int64PointerValue(updateCommon.OauthConfigurationId) plan.AdapterVersion = types.StringValue(warehouseConfigChanges.AdapterVersion()) default: diff --git a/pkg/framework/objects/global_connection/resource_acceptance_test.go b/pkg/framework/objects/global_connection/resource_acceptance_test.go index 0a8a347..0b176a7 100644 --- a/pkg/framework/objects/global_connection/resource_acceptance_test.go +++ b/pkg/framework/objects/global_connection/resource_acceptance_test.go @@ -65,6 +65,10 @@ func TestAccDbtCloudGlobalConnectionSnowflakeResource(t *testing.T) { "is_ssh_tunnel_enabled", "false", ), + resource.TestCheckResourceAttrSet( + "dbtcloud_global_connection.test", + "oauth_configuration_id", + ), ), }, // modify, removing optional fields to check PATCH when we remove fields @@ -111,6 +115,18 @@ func testAccDbtCloudSGlobalConnectionSnowflakeResourceBasicConfig( ) string { return fmt.Sprintf(` +resource dbtcloud_oauth_configuration test { + type = "entra" + name = "OAuth config" + client_secret = "secret" + client_id = "myid" + redirect_uri = "http://example.com" + token_url = "http://example.com" + authorize_url = "http://example.com" + application_id_uri = "app-uri" +} + + resource dbtcloud_global_connection test { name = "%s" @@ -132,8 +148,22 @@ func testAccDbtCloudSGlobalConnectionSnowflakeResourceFullConfig( connectionName string, ) string { return fmt.Sprintf(` + +resource dbtcloud_oauth_configuration test { + type = "entra" + name = "OAuth config" + client_secret = "secret" + client_id = "myid" + redirect_uri = "http://example.com" + token_url = "http://example.com" + authorize_url = "http://example.com" + application_id_uri = "app-uri" +} + + resource dbtcloud_global_connection test { name = "%s" + oauth_configuration_id = dbtcloud_oauth_configuration.test.id snowflake = { account = "account" diff --git a/pkg/framework/objects/global_connection/schema.go b/pkg/framework/objects/global_connection/schema.go index f5c2139..da9cbfc 100644 --- a/pkg/framework/objects/global_connection/schema.go +++ b/pkg/framework/objects/global_connection/schema.go @@ -59,7 +59,8 @@ func (r *globalConnectionResource) Schema( Description: "Private Link Endpoint ID. This ID can be found using the `privatelink_endpoint` data source", }, "oauth_configuration_id": resource_schema.Int64Attribute{ - Computed: true, + Optional: true, + Description: "External OAuth configuration ID (only Snowflake for now)", }, "bigquery": resource_schema.SingleNestedAttribute{ Optional: true, From d9cb776db6b63ce38fcf3883e09e48e91eb542b6 Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:42:24 +0100 Subject: [PATCH 4/5] Add new resource for oauth configurations --- docs/resources/oauth_configuration.md | 81 +++++ .../dbtcloud_oauth_configuration/import.sh | 14 + .../dbtcloud_oauth_configuration/resource.tf | 21 ++ pkg/dbt_cloud/oauth_configuration.go | 194 ++++++++++++ .../objects/oauth_configuration/model.go | 17 + .../objects/oauth_configuration/resource.go | 290 ++++++++++++++++++ .../resource_acceptance_test.go | 236 ++++++++++++++ .../objects/oauth_configuration/schema.go | 80 +++++ pkg/provider/framework_provider.go | 2 + 9 files changed, 935 insertions(+) create mode 100644 docs/resources/oauth_configuration.md create mode 100644 examples/resources/dbtcloud_oauth_configuration/import.sh create mode 100644 examples/resources/dbtcloud_oauth_configuration/resource.tf create mode 100644 pkg/dbt_cloud/oauth_configuration.go create mode 100644 pkg/framework/objects/oauth_configuration/model.go create mode 100644 pkg/framework/objects/oauth_configuration/resource.go create mode 100644 pkg/framework/objects/oauth_configuration/resource_acceptance_test.go create mode 100644 pkg/framework/objects/oauth_configuration/schema.go diff --git a/docs/resources/oauth_configuration.md b/docs/resources/oauth_configuration.md new file mode 100644 index 0000000..60d28a8 --- /dev/null +++ b/docs/resources/oauth_configuration.md @@ -0,0 +1,81 @@ +--- +page_title: "dbtcloud_oauth_configuration Resource - dbtcloud" +subcategory: "" +description: |- + Configure an external OAuth integration for the data warehouse. Currently supports Okta and Entra ID (i.e. Azure AD) for Snowflake. + See the documentation https://docs.getdbt.com/docs/cloud/manage-access/external-oauth for more information on how to configure it. +--- + +# dbtcloud_oauth_configuration (Resource) + + +Configure an external OAuth integration for the data warehouse. Currently supports Okta and Entra ID (i.e. Azure AD) for Snowflake. + +See the [documentation](https://docs.getdbt.com/docs/cloud/manage-access/external-oauth) for more information on how to configure it. + +## Example Usage + +```terraform +resource "dbtcloud_oauth_configuration" "test" { + type = "entra" + name = "My Entra ID Oauth integration" + client_id = "client-id" + client_secret = "client-secret" + redirect_uri = "http://example.com" + token_url = "http://example.com" + authorize_url = "http://example.com" + application_id_uri = "uri" +} + +resource "dbtcloud_oauth_configuration" "test" { + type = "okta" + name = "My Okta Oauth integration" + client_id = "client-id" + client_secret = "client-secret" + redirect_uri = "http://example.com" + token_url = "http://example.com" + authorize_url = "http://example.com" +} +``` + + +## Schema + +### Required + +- `authorize_url` (String) The Authorize URL for the OAuth integration +- `client_id` (String) The Client ID for the OAuth integration +- `client_secret` (String, Sensitive) The Client secret for the OAuth integration +- `name` (String) The name of OAuth integration +- `redirect_uri` (String) The redirect URL for the OAuth integration +- `token_url` (String) The Token URL for the OAuth integration +- `type` (String) The type of OAuth integration (`entra` or `okta`) + +### Optional + +- `application_id_uri` (String) The Application ID URI for the OAuth integration. Only for Entra + +### Read-Only + +- `id` (Number) The ID of the OAuth configuration + +## Import + +Import is supported using the following syntax: + +```shell +# using import blocks (requires Terraform >= 1.5) +import { + to = dbtcloud_oauth_configuration.my_external_oauth + id = "external_oauth_id" +} + +import { + to = dbtcloud_oauth_configuration.my_external_oauth + id = "12345" +} + +# using the older import command +terraform import dbtcloud_oauth_configuration.my_external_oauth "external_oauth_id" +terraform import dbtcloud_oauth_configuration.my_external_oauth 12345 +``` diff --git a/examples/resources/dbtcloud_oauth_configuration/import.sh b/examples/resources/dbtcloud_oauth_configuration/import.sh new file mode 100644 index 0000000..dee2de7 --- /dev/null +++ b/examples/resources/dbtcloud_oauth_configuration/import.sh @@ -0,0 +1,14 @@ +# using import blocks (requires Terraform >= 1.5) +import { + to = dbtcloud_oauth_configuration.my_external_oauth + id = "external_oauth_id" +} + +import { + to = dbtcloud_oauth_configuration.my_external_oauth + id = "12345" +} + +# using the older import command +terraform import dbtcloud_oauth_configuration.my_external_oauth "external_oauth_id" +terraform import dbtcloud_oauth_configuration.my_external_oauth 12345 diff --git a/examples/resources/dbtcloud_oauth_configuration/resource.tf b/examples/resources/dbtcloud_oauth_configuration/resource.tf new file mode 100644 index 0000000..2c92078 --- /dev/null +++ b/examples/resources/dbtcloud_oauth_configuration/resource.tf @@ -0,0 +1,21 @@ + +resource "dbtcloud_oauth_configuration" "test" { + type = "entra" + name = "My Entra ID Oauth integration" + client_id = "client-id" + client_secret = "client-secret" + redirect_uri = "http://example.com" + token_url = "http://example.com" + authorize_url = "http://example.com" + application_id_uri = "uri" +} + +resource "dbtcloud_oauth_configuration" "test" { + type = "okta" + name = "My Okta Oauth integration" + client_id = "client-id" + client_secret = "client-secret" + redirect_uri = "http://example.com" + token_url = "http://example.com" + authorize_url = "http://example.com" +} \ No newline at end of file diff --git a/pkg/dbt_cloud/oauth_configuration.go b/pkg/dbt_cloud/oauth_configuration.go new file mode 100644 index 0000000..952f9c7 --- /dev/null +++ b/pkg/dbt_cloud/oauth_configuration.go @@ -0,0 +1,194 @@ +package dbt_cloud + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +type OAuthConfiguration struct { + ID *int64 `json:"id,omitempty"` + AccountId int64 `json:"account_id"` + Type string `json:"type"` + Name string `json:"name"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthorizeUrl string `json:"authorize_url"` + TokenUrl string `json:"token_url"` + RedirectUri string `json:"redirect_uri"` + OAuthConfigurationExtra *OAuthConfigurationExtra `json:"extra_data,omitempty"` +} + +type OAuthConfigurationExtra struct { + ApplicationIdUri *string `json:"application_id_uri,omitempty"` +} + +type OAuthConfigurationListResponse struct { + Data []OAuthConfiguration `json:"data"` + Status ResponseStatus `json:"status"` + Extra ResponseExtra `json:"extra"` +} + +type OAuthConfigurationResponse struct { + Data OAuthConfiguration `json:"data"` + Status ResponseStatus `json:"status"` +} + +func (c *Client) GetOAuthConfiguration(oAuthConfigurationID int64) (*OAuthConfiguration, error) { + req, err := http.NewRequest( + "GET", + fmt.Sprintf( + "%s/v3/accounts/%s/oauth-configurations/%d/", + c.HostURL, + strconv.Itoa(c.AccountID), + oAuthConfigurationID, + ), + nil, + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + oAuthConfigurationResponse := OAuthConfigurationResponse{} + err = json.Unmarshal(body, &oAuthConfigurationResponse) + if err != nil { + return nil, err + } + + return &oAuthConfigurationResponse.Data, nil +} + +func (c *Client) CreateOAuthConfiguration( + oAuthType string, + name string, + clientId string, + clientSecret string, + authorizeUrl string, + tokenUrl string, + redirectUri string, + applicationURI string, +) (*OAuthConfiguration, error) { + newOAuthConfiguration := OAuthConfiguration{ + AccountId: int64(c.AccountID), + Type: oAuthType, + Name: name, + ClientId: clientId, + ClientSecret: clientSecret, + AuthorizeUrl: authorizeUrl, + TokenUrl: tokenUrl, + RedirectUri: redirectUri, + } + + if applicationURI != "" { + newOAuthConfigurationExtra := OAuthConfigurationExtra{ + ApplicationIdUri: &applicationURI, + } + newOAuthConfiguration.OAuthConfigurationExtra = &newOAuthConfigurationExtra + } + + newOAuthConfigurationData, err := json.Marshal(newOAuthConfiguration) + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "POST", + fmt.Sprintf( + "%s/v3/accounts/%s/oauth-configurations/", + c.HostURL, + strconv.Itoa(c.AccountID), + ), + strings.NewReader(string(newOAuthConfigurationData)), + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + oAuthConfigurationResponse := OAuthConfigurationResponse{} + err = json.Unmarshal(body, &oAuthConfigurationResponse) + if err != nil { + return nil, err + } + + return &oAuthConfigurationResponse.Data, nil +} + +func (c *Client) UpdateOAuthConfiguration( + oAuthConfigurationID int64, + oAuthConfiguration OAuthConfiguration, +) (*OAuthConfiguration, error) { + oAuthConfigurationData, err := json.Marshal(oAuthConfiguration) + if err != nil { + return nil, err + } + + req, err := http.NewRequest( + "POST", + fmt.Sprintf( + "%s/v3/accounts/%s/oauth-configurations/%d/", + c.HostURL, + strconv.Itoa(c.AccountID), + oAuthConfigurationID, + ), + strings.NewReader(string(oAuthConfigurationData)), + ) + if err != nil { + return nil, err + } + + body, err := c.doRequest(req) + if err != nil { + return nil, err + } + + oAuthConfigurationResponse := OAuthConfigurationResponse{} + err = json.Unmarshal(body, &oAuthConfigurationResponse) + if err != nil { + return nil, err + } + + return &oAuthConfigurationResponse.Data, nil +} + +func (c *Client) DeleteOAuthConfiguration( + oAuthConfigurationID int64, +) error { + req, err := http.NewRequest( + "DELETE", + fmt.Sprintf( + "%s/v3/accounts/%s/oauth-configurations/%d/", + c.HostURL, + strconv.Itoa(c.AccountID), + oAuthConfigurationID, + ), + nil, + ) + if err != nil { + return err + } + + body, err := c.doRequest(req) + if err != nil { + return err + } + + oAuthConfigurationResponse := OAuthConfigurationResponse{} + err = json.Unmarshal(body, &oAuthConfigurationResponse) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/framework/objects/oauth_configuration/model.go b/pkg/framework/objects/oauth_configuration/model.go new file mode 100644 index 0000000..6cc32a5 --- /dev/null +++ b/pkg/framework/objects/oauth_configuration/model.go @@ -0,0 +1,17 @@ +package oauth_configuration + +import ( + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type OAuthConfigurationResourceModel struct { + ID types.Int64 `tfsdk:"id"` + Type types.String `tfsdk:"type"` + Name types.String `tfsdk:"name"` + ClientId types.String `tfsdk:"client_id"` + ClientSecret types.String `tfsdk:"client_secret"` + AuthorizeUrl types.String `tfsdk:"authorize_url"` + TokenUrl types.String `tfsdk:"token_url"` + RedirectUri types.String `tfsdk:"redirect_uri"` + ApplicationIdUri types.String `tfsdk:"application_id_uri"` +} diff --git a/pkg/framework/objects/oauth_configuration/resource.go b/pkg/framework/objects/oauth_configuration/resource.go new file mode 100644 index 0000000..81cfc89 --- /dev/null +++ b/pkg/framework/objects/oauth_configuration/resource.go @@ -0,0 +1,290 @@ +package oauth_configuration + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/dbt_cloud" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &oAuthConfigurationResource{} + _ resource.ResourceWithConfigure = &oAuthConfigurationResource{} + _ resource.ResourceWithImportState = &oAuthConfigurationResource{} + _ resource.ResourceWithValidateConfig = &oAuthConfigurationResource{} +) + +func OAuthConfigurationResource() resource.Resource { + return &oAuthConfigurationResource{} +} + +type oAuthConfigurationResource struct { + client *dbt_cloud.Client +} + +func (r *oAuthConfigurationResource) Metadata( + _ context.Context, + req resource.MetadataRequest, + resp *resource.MetadataResponse, +) { + resp.TypeName = req.ProviderTypeName + "_oauth_configuration" +} + +func (r *oAuthConfigurationResource) ValidateConfig( + ctx context.Context, + req resource.ValidateConfigRequest, + resp *resource.ValidateConfigResponse, +) { + var data OAuthConfigurationResourceModel + + resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + if data.Type.ValueString() == "okta" && !data.ApplicationIdUri.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("application_id_uri"), + "application_id_uri is not supported for Okta", + "application_id_uri is only supported for Entra ID (i.e. Azure AD) OAuth integrations", + ) + } + + if data.Type.ValueString() == "entra" && data.ApplicationIdUri.IsNull() { + resp.Diagnostics.AddAttributeError( + path.Root("application_id_uri"), + "application_id_uri is required for Entra ID", + "application_id_uri is required for Entra ID (i.e. Azure AD) OAuth integrations", + ) + } +} + +func (r *oAuthConfigurationResource) Read( + ctx context.Context, + req resource.ReadRequest, + resp *resource.ReadResponse, +) { + var state OAuthConfigurationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + oAuthConfigurationID := state.ID.ValueInt64() + retrievedOAuthConfiguration, err := r.client.GetOAuthConfiguration(oAuthConfigurationID) + + if err != nil { + if strings.HasPrefix(err.Error(), "resource-not-found") { + resp.Diagnostics.AddWarning( + "Resource not found", + "The OAuth configuration was not found and has been removed from the state.", + ) + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error getting the OAuth configuration", err.Error()) + return + } + + state.ID = types.Int64Value(int64(*retrievedOAuthConfiguration.ID)) + state.Type = types.StringValue(retrievedOAuthConfiguration.Type) + state.Name = types.StringValue(retrievedOAuthConfiguration.Name) + state.ClientId = types.StringValue(retrievedOAuthConfiguration.ClientId) + state.AuthorizeUrl = types.StringValue(retrievedOAuthConfiguration.AuthorizeUrl) + state.TokenUrl = types.StringValue(retrievedOAuthConfiguration.TokenUrl) + state.RedirectUri = types.StringValue(retrievedOAuthConfiguration.RedirectUri) + + if retrievedOAuthConfiguration.OAuthConfigurationExtra != nil { + state.ApplicationIdUri = types.StringValue( + *retrievedOAuthConfiguration.OAuthConfigurationExtra.ApplicationIdUri, + ) + } else { + state.ApplicationIdUri = types.StringValue("") + } + + // secrets are not set when reading. + // Here the only secret is `client_secret` + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + +} + +func (r *oAuthConfigurationResource) Create( + ctx context.Context, + req resource.CreateRequest, + resp *resource.CreateResponse, +) { + var plan OAuthConfigurationResourceModel + + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + oAuthType := plan.Type.ValueString() + name := plan.Name.ValueString() + clientID := plan.ClientId.ValueString() + clientSecret := plan.ClientSecret.ValueString() + authorizeURL := plan.AuthorizeUrl.ValueString() + tokenURL := plan.TokenUrl.ValueString() + redirectURI := plan.RedirectUri.ValueString() + + // will be set to "" if not configured + applicationURI := plan.ApplicationIdUri.ValueString() + + createdOAuthConfiguration, err := r.client.CreateOAuthConfiguration( + oAuthType, + name, + clientID, + clientSecret, + authorizeURL, + tokenURL, + redirectURI, + applicationURI, + ) + if err != nil { + resp.Diagnostics.AddError( + "Unable to create OAuth configuration", + "Error: "+err.Error(), + ) + return + } + + plan.ID = types.Int64Value(*createdOAuthConfiguration.ID) + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *oAuthConfigurationResource) Delete( + ctx context.Context, + req resource.DeleteRequest, + resp *resource.DeleteResponse, +) { + var state OAuthConfigurationResourceModel + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + oAuthConfigurationID := state.ID.ValueInt64() + + err := r.client.DeleteOAuthConfiguration(oAuthConfigurationID) + + if err != nil { + resp.Diagnostics.AddError( + "Issue deleting OAuthConfiguration", + "Error: "+err.Error(), + ) + return + } +} + +func (r *oAuthConfigurationResource) Update( + ctx context.Context, + req resource.UpdateRequest, + resp *resource.UpdateResponse, +) { + var plan, state OAuthConfigurationResourceModel + + // Read plan and state values into the models + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + oAuthConfigurationID := state.ID.ValueInt64() + + retrievedOAuthConfiguration, err := r.client.GetOAuthConfiguration(oAuthConfigurationID) + if err != nil { + resp.Diagnostics.AddError( + "Issue getting OAuth configuration", + "Error: "+err.Error(), + ) + return + } + + // we check the fields which don't trigger a new resource + if plan.Name != state.Name { + retrievedOAuthConfiguration.Name = plan.Name.ValueString() + } + if plan.ClientId != state.ClientId { + retrievedOAuthConfiguration.ClientId = plan.ClientId.ValueString() + } + if plan.ClientSecret != state.ClientSecret { + retrievedOAuthConfiguration.ClientSecret = plan.ClientSecret.ValueString() + } + if plan.AuthorizeUrl != state.AuthorizeUrl { + retrievedOAuthConfiguration.AuthorizeUrl = plan.AuthorizeUrl.ValueString() + } + if plan.TokenUrl != state.TokenUrl { + retrievedOAuthConfiguration.TokenUrl = plan.TokenUrl.ValueString() + } + if plan.RedirectUri != state.RedirectUri { + retrievedOAuthConfiguration.RedirectUri = plan.RedirectUri.ValueString() + } + if plan.ApplicationIdUri != state.ApplicationIdUri { + if plan.ApplicationIdUri.IsNull() { + retrievedOAuthConfiguration.OAuthConfigurationExtra = nil + } else { + applicationIdUri := plan.ApplicationIdUri.ValueString() + retrievedOAuthConfiguration.OAuthConfigurationExtra = &dbt_cloud.OAuthConfigurationExtra{ + ApplicationIdUri: &applicationIdUri, + } + } + } + + _, err = r.client.UpdateOAuthConfiguration( + oAuthConfigurationID, + *retrievedOAuthConfiguration, + ) + if err != nil { + resp.Diagnostics.AddError( + "Unable to update OAuth configuration", + "Error: "+err.Error(), + ) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *oAuthConfigurationResource) ImportState( + ctx context.Context, + req resource.ImportStateRequest, + resp *resource.ImportStateResponse, +) { + + // I think we need this conversion because the ID is a string + oAuthConfigurationIDStr := req.ID + oAuthConfigurationID, err := strconv.Atoi(oAuthConfigurationIDStr) + if err != nil { + fmt.Println(err) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute( + ctx, path.Root("id"), oAuthConfigurationID, + )...) + +} + +func (r *oAuthConfigurationResource) Configure( + _ context.Context, + req resource.ConfigureRequest, + _ *resource.ConfigureResponse, +) { + if req.ProviderData == nil { + return + } + + r.client = req.ProviderData.(*dbt_cloud.Client) +} diff --git a/pkg/framework/objects/oauth_configuration/resource_acceptance_test.go b/pkg/framework/objects/oauth_configuration/resource_acceptance_test.go new file mode 100644 index 0000000..7ce174d --- /dev/null +++ b/pkg/framework/objects/oauth_configuration/resource_acceptance_test.go @@ -0,0 +1,236 @@ +package oauth_configuration_test + +import ( + "fmt" + "regexp" + "strconv" + "testing" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/acctest_helper" + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccDbtCloudOAuthConfigurationOktaResource(t *testing.T) { + + oAuthType := "okta" + oAuthConfigurationName := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientId := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientSecret := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + authorizeUrl := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + tokenUrl := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + redirectUri := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + + oAuthConfigurationName2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientId2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientSecret2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + authorizeUrl2 := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + tokenUrl2 := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + redirectUri2 := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest_helper.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest_helper.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDbtCloudOAuthConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDbtCloudOAuthConfigurationResourceBasicConfig( + oAuthType, + oAuthConfigurationName, + oauthClientId, + oauthClientSecret, + authorizeUrl, + tokenUrl, + redirectUri, + "", + ), + // we just need to check the ones that have special logics or are computed + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_oauth_configuration.test_oauth_configuration", + "client_secret", + oauthClientSecret, + ), + ), + }, + // MODIFY + { + Config: testAccDbtCloudOAuthConfigurationResourceBasicConfig( + oAuthType, + oAuthConfigurationName2, + oauthClientId2, + oauthClientSecret2, + authorizeUrl2, + tokenUrl2, + redirectUri2, + "", + ), + // we just need to check the ones that have special logics or are computed + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_oauth_configuration.test_oauth_configuration", + "client_secret", + oauthClientSecret2, + ), + ), + }, + // IMPORT + { + ResourceName: "dbtcloud_oauth_configuration.test_oauth_configuration", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"client_secret"}, + }, + }, + }) +} + +func TestAccDbtCloudOAuthConfigurationEntraResource(t *testing.T) { + + oAuthType := "entra" + oAuthConfigurationName := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientId := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientSecret := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + authorizeUrl := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + tokenUrl := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + redirectUri := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + applicationIdUri := "https://" + acctest.RandStringFromCharSet( + 10, + acctest.CharSetAlpha, + ) + ".com" + + oAuthConfigurationName2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientId2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + oauthClientSecret2 := acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + authorizeUrl2 := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + tokenUrl2 := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + redirectUri2 := "https://" + acctest.RandStringFromCharSet(10, acctest.CharSetAlpha) + ".com" + applicationIdUri2 := "https://" + acctest.RandStringFromCharSet( + 10, + acctest.CharSetAlpha, + ) + ".com" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { acctest_helper.TestAccPreCheck(t) }, + ProtoV6ProviderFactories: acctest_helper.TestAccProtoV6ProviderFactories, + CheckDestroy: testAccCheckDbtCloudOAuthConfigurationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccDbtCloudOAuthConfigurationResourceBasicConfig( + oAuthType, + oAuthConfigurationName, + oauthClientId, + oauthClientSecret, + authorizeUrl, + tokenUrl, + redirectUri, + applicationIdUri, + ), + // we just need to check the ones that have special logics or are computed + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_oauth_configuration.test_oauth_configuration", + "client_secret", + oauthClientSecret, + ), + ), + }, + // MODIFY + { + Config: testAccDbtCloudOAuthConfigurationResourceBasicConfig( + oAuthType, + oAuthConfigurationName2, + oauthClientId2, + oauthClientSecret2, + authorizeUrl2, + tokenUrl2, + redirectUri2, + applicationIdUri2, + ), + // we just need to check the ones that have special logics or are computed + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "dbtcloud_oauth_configuration.test_oauth_configuration", + "client_secret", + oauthClientSecret2, + ), + ), + }, + // IMPORT + { + ResourceName: "dbtcloud_oauth_configuration.test_oauth_configuration", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"client_secret"}, + }, + }, + }) +} + +func testAccDbtCloudOAuthConfigurationResourceBasicConfig( + oAuthType, + oAuthConfigurationName, + oauth_client_id, + oauth_client_secret, + authorize_url, + token_url, + redirect_uri, + application_id_uri string, +) string { + + application_id_uri_config := "" + if oAuthType == "entra" { + application_id_uri_config = fmt.Sprintf(` + application_id_uri = "%s" + `, application_id_uri) + } + + return fmt.Sprintf(` +resource "dbtcloud_oauth_configuration" "test_oauth_configuration" { + type = "%s" + name = "%s" + client_id = "%s" + client_secret = "%s" + authorize_url = "%s" + token_url = "%s" + redirect_uri = "%s" + %s +} + `, oAuthType, + oAuthConfigurationName, + oauth_client_id, + oauth_client_secret, + authorize_url, + token_url, + redirect_uri, + application_id_uri_config) +} + +func testAccCheckDbtCloudOAuthConfigurationDestroy(s *terraform.State) error { + apiClient, err := acctest_helper.SharedClient() + if err != nil { + return fmt.Errorf("Issue getting the client") + } + + for _, rs := range s.RootModule().Resources { + if rs.Type != "dbtcloud_oauth_configuration" { + continue + } + oAuthConfigurationID, err := strconv.Atoi(rs.Primary.ID) + if err != nil { + return fmt.Errorf("Can't get oAuthConfigurationID") + } + _, err = apiClient.GetOAuthConfiguration(int64(oAuthConfigurationID)) + if err == nil { + return fmt.Errorf("OAuthConfiguration still exists") + } + notFoundErr := "resource-not-found" + expectedErr := regexp.MustCompile(notFoundErr) + if !expectedErr.Match([]byte(err.Error())) { + return fmt.Errorf("expected %s, got %s", notFoundErr, err) + } + } + + return nil +} diff --git a/pkg/framework/objects/oauth_configuration/schema.go b/pkg/framework/objects/oauth_configuration/schema.go new file mode 100644 index 0000000..27e480c --- /dev/null +++ b/pkg/framework/objects/oauth_configuration/schema.go @@ -0,0 +1,80 @@ +package oauth_configuration + +import ( + "context" + + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/helper" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/resource" + resource_schema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" +) + +func (r *oAuthConfigurationResource) Schema( + _ context.Context, + _ resource.SchemaRequest, + resp *resource.SchemaResponse, +) { + resp.Schema = resource_schema.Schema{ + Description: helper.DocString( + `Configure an external OAuth integration for the data warehouse. Currently supports Okta and Entra ID (i.e. Azure AD) for Snowflake. + + See the [documentation](https://docs.getdbt.com/docs/cloud/manage-access/external-oauth) for more information on how to configure it.`, + ), + Attributes: map[string]resource_schema.Attribute{ + "id": resource_schema.Int64Attribute{ + Computed: true, + Description: "The ID of the OAuth configuration", + // this is used so that we don't show that ID is going to change + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + }, + "type": resource_schema.StringAttribute{ + Required: true, + Description: "The type of OAuth integration (`entra` or `okta`)", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + stringvalidator.OneOf("okta", "entra"), + }, + }, + "name": resource_schema.StringAttribute{ + Required: true, + Description: "The name of OAuth integration", + }, + "client_id": resource_schema.StringAttribute{ + Required: true, + Description: "The Client ID for the OAuth integration", + }, + "client_secret": resource_schema.StringAttribute{ + Required: true, + Description: "The Client secret for the OAuth integration", + Sensitive: true, + }, + "authorize_url": resource_schema.StringAttribute{ + Required: true, + Description: "The Authorize URL for the OAuth integration", + }, + "token_url": resource_schema.StringAttribute{ + Required: true, + Description: "The Token URL for the OAuth integration", + }, + "redirect_uri": resource_schema.StringAttribute{ + Required: true, + Description: "The redirect URL for the OAuth integration", + }, + "application_id_uri": resource_schema.StringAttribute{ + Optional: true, + Computed: true, + Description: "The Application ID URI for the OAuth integration. Only for Entra", + Default: stringdefault.StaticString(""), + }, + }, + } +} diff --git a/pkg/provider/framework_provider.go b/pkg/provider/framework_provider.go index 81eb671..ba5f10d 100644 --- a/pkg/provider/framework_provider.go +++ b/pkg/provider/framework_provider.go @@ -13,6 +13,7 @@ import ( "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/job" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/lineage_integration" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/notification" + "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/oauth_configuration" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/partial_license_map" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/partial_notification" "github.com/dbt-labs/terraform-provider-dbtcloud/pkg/framework/objects/project" @@ -202,5 +203,6 @@ func (p *dbtCloudProvider) Resources(_ context.Context) []func() resource.Resour service_token.ServiceTokenResource, global_connection.GlobalConnectionResource, lineage_integration.LineageIntegrationResource, + oauth_configuration.OAuthConfigurationResource, } } From bc2755db556db39c7198fcc582da58cee277775d Mon Sep 17 00:00:00 2001 From: Benoit Perigaud <8754100+b-per@users.noreply.github.com> Date: Tue, 5 Nov 2024 12:42:39 +0100 Subject: [PATCH 5/5] Update changelog for new release --- CHANGELOG.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38efd5a..f9ba46f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,18 @@ All notable changes to this project will be documented in this file. -## [Unreleased](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.20...HEAD) +## [Unreleased](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.21...HEAD) + +# [0.3.21](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.20...v0.3.21) + +### Changes + +- Allow setting external OAuth config for global connections in Snowflake +- Add resource `dbtcloud_oauth_configuration` to define external OAuth integrations + +### Fixes + +- Fix acceptance test for jobs when using the ability to compare changes # [0.3.20](https://github.com/dbt-labs/terraform-provider-dbtcloud/compare/v0.3.19...v0.3.20)