From 6088aa8635031b740b92ab7b30531c136193cf30 Mon Sep 17 00:00:00 2001 From: vuong-nguyen <44292934+nkvuong@users.noreply.github.com> Date: Sun, 20 Oct 2024 16:51:16 +0800 Subject: [PATCH] [Feature] Added resource `databricks_custom_app_integration` (#4124) ## Changes Add resource `databricks_custom_app_integration` for OAuth custom app integration ## Tests - [x] `make test` run locally - [x] relevant change in `docs/` folder - [x] covered with integration tests in `internal/acceptance` - [x] relevant acceptance tests are passing - [x] using Go SDK --- apps/resource_custom_app_integration.go | 80 ++++++++ apps/resource_custom_app_integration_test.go | 175 ++++++++++++++++++ docs/resources/custom_app_integration.md | 58 ++++++ .../acceptance/custom_app_integration_test.go | 34 ++++ internal/providers/sdkv2/sdkv2.go | 2 + 5 files changed, 349 insertions(+) create mode 100644 apps/resource_custom_app_integration.go create mode 100644 apps/resource_custom_app_integration_test.go create mode 100644 docs/resources/custom_app_integration.md create mode 100644 internal/acceptance/custom_app_integration_test.go diff --git a/apps/resource_custom_app_integration.go b/apps/resource_custom_app_integration.go new file mode 100644 index 0000000000..30a604abb1 --- /dev/null +++ b/apps/resource_custom_app_integration.go @@ -0,0 +1,80 @@ +package apps + +import ( + "context" + + "github.com/databricks/databricks-sdk-go/service/oauth2" + "github.com/databricks/terraform-provider-databricks/common" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +type CustomAppIntegration struct { + oauth2.GetCustomAppIntegrationOutput + // OAuth client-secret generated by the Databricks. If this is a + // confidential OAuth app client-secret will be generated. + ClientSecret string `json:"client_secret,omitempty"` +} + +func ResourceCustomAppIntegration() common.Resource { + s := common.StructToSchema(CustomAppIntegration{}, func(m map[string]*schema.Schema) map[string]*schema.Schema { + for _, p := range []string{"client_id", "create_time", "created_by", "creator_username", "integration_id"} { + common.CustomizeSchemaPath(m, p).SetComputed() + } + for _, p := range []string{"confidential", "name", "scopes"} { + common.CustomizeSchemaPath(m, p).SetForceNew() + } + common.CustomizeSchemaPath(m, "client_secret").SetSensitive().SetComputed() + common.CustomizeSchemaPath(m, "token_access_policy", "access_token_ttl_in_minutes").SetValidateFunc(validation.IntBetween(5, 1440)) + common.CustomizeSchemaPath(m, "token_access_policy", "refresh_token_ttl_in_minutes").SetValidateFunc(validation.IntBetween(5, 129600)) + return m + }) + return common.Resource{ + Create: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var create oauth2.CreateCustomAppIntegration + common.DataToStructPointer(d, s, &create) + acc, err := c.AccountClient() + if err != nil { + return err + } + integration, err := acc.CustomAppIntegration.Create(ctx, create) + if err != nil { + return err + } + d.Set("integration_id", integration.IntegrationId) + d.Set("client_id", integration.ClientId) + d.Set("client_secret", integration.ClientSecret) + d.SetId(integration.IntegrationId) + return nil + }, + Read: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + acc, err := c.AccountClient() + if err != nil { + return err + } + integration, err := acc.CustomAppIntegration.GetByIntegrationId(ctx, d.Id()) + if err != nil { + return err + } + return common.StructToData(integration, s, d) + }, + Update: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + var update oauth2.UpdateCustomAppIntegration + update.IntegrationId = d.Id() + common.DataToStructPointer(d, s, &update) + acc, err := c.AccountClient() + if err != nil { + return err + } + return acc.CustomAppIntegration.Update(ctx, update) + }, + Delete: func(ctx context.Context, d *schema.ResourceData, c *common.DatabricksClient) error { + acc, err := c.AccountClient() + if err != nil { + return err + } + return acc.CustomAppIntegration.DeleteByIntegrationId(ctx, d.Id()) + }, + Schema: s, + } +} diff --git a/apps/resource_custom_app_integration_test.go b/apps/resource_custom_app_integration_test.go new file mode 100644 index 0000000000..37034fad10 --- /dev/null +++ b/apps/resource_custom_app_integration_test.go @@ -0,0 +1,175 @@ +package apps + +import ( + "testing" + + "github.com/databricks/databricks-sdk-go/experimental/mocks" + "github.com/databricks/databricks-sdk-go/service/oauth2" + "github.com/stretchr/testify/mock" + + "github.com/databricks/terraform-provider-databricks/qa" +) + +func TestResourceCustomAppIntegrationCreate(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + api := a.GetMockCustomAppIntegrationAPI().EXPECT() + api.Create(mock.Anything, oauth2.CreateCustomAppIntegration{ + Name: "custom_integration_name", + RedirectUrls: []string{ + "https://example.com", + }, + Scopes: []string{ + "all", + }, + TokenAccessPolicy: &oauth2.TokenAccessPolicy{ + AccessTokenTtlInMinutes: 60, + RefreshTokenTtlInMinutes: 30, + }, + }).Return(&oauth2.CreateCustomAppIntegrationOutput{ + ClientId: "client_id", + ClientSecret: "client_secret", + IntegrationId: "integration_id", + }, nil) + api.GetByIntegrationId(mock.Anything, "integration_id").Return( + &oauth2.GetCustomAppIntegrationOutput{ + Name: "custom_integration_name", + RedirectUrls: []string{ + "https://example.com", + }, + Scopes: []string{ + "all", + }, + TokenAccessPolicy: &oauth2.TokenAccessPolicy{ + AccessTokenTtlInMinutes: 60, + RefreshTokenTtlInMinutes: 30, + }, + ClientId: "client_id", + IntegrationId: "integration_id", + }, nil, + ) + }, + Create: true, + AccountID: "account_id", + HCL: ` + name = "custom_integration_name" + redirect_urls = ["https://example.com"] + scopes = ["all"] + token_access_policy { + access_token_ttl_in_minutes = 60 + refresh_token_ttl_in_minutes = 30 + }`, + Resource: ResourceCustomAppIntegration(), + }.ApplyAndExpectData(t, map[string]any{ + "name": "custom_integration_name", + "integration_id": "integration_id", + "client_id": "client_id", + "client_secret": "client_secret", + }) +} + +func TestResourceCustomAppIntegrationRead(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + a.GetMockCustomAppIntegrationAPI().EXPECT().GetByIntegrationId(mock.Anything, "integration_id").Return( + &oauth2.GetCustomAppIntegrationOutput{ + Name: "custom_integration_name", + RedirectUrls: []string{ + "https://example.com", + }, + Scopes: []string{ + "all", + }, + TokenAccessPolicy: &oauth2.TokenAccessPolicy{ + AccessTokenTtlInMinutes: 60, + RefreshTokenTtlInMinutes: 30, + }, + ClientId: "client_id", + IntegrationId: "integration_id", + }, nil, + ) + }, + Resource: ResourceCustomAppIntegration(), + Read: true, + New: true, + AccountID: "account_id", + ID: "integration_id", + }.ApplyAndExpectData(t, map[string]any{ + "name": "custom_integration_name", + "integration_id": "integration_id", + "client_id": "client_id", + }) +} + +func TestResourceCustomAppIntegrationUpdate(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + api := a.GetMockCustomAppIntegrationAPI().EXPECT() + api.Update(mock.Anything, oauth2.UpdateCustomAppIntegration{ + IntegrationId: "integration_id", + RedirectUrls: []string{ + "https://example.com", + }, + TokenAccessPolicy: &oauth2.TokenAccessPolicy{ + AccessTokenTtlInMinutes: 30, + RefreshTokenTtlInMinutes: 30, + }, + }).Return(nil) + api.GetByIntegrationId(mock.Anything, "integration_id").Return( + &oauth2.GetCustomAppIntegrationOutput{ + Name: "custom_integration_name", + RedirectUrls: []string{ + "https://example.com", + }, + Scopes: []string{ + "all", + }, + TokenAccessPolicy: &oauth2.TokenAccessPolicy{ + AccessTokenTtlInMinutes: 30, + RefreshTokenTtlInMinutes: 30, + }, + ClientId: "client_id", + IntegrationId: "integration_id", + }, nil, + ) + }, + Resource: ResourceCustomAppIntegration(), + Update: true, + HCL: ` + name = "custom_integration_name" + redirect_urls = ["https://example.com"] + scopes = ["all"] + token_access_policy { + access_token_ttl_in_minutes = 30 + refresh_token_ttl_in_minutes = 30 + }`, + InstanceState: map[string]string{ + "name": "custom_integration_name", + "integration_id": "integration_id", + "client_id": "client_id", + "scopes.#": "1", + "scopes.0": "all", + "redirect_urls.#": "1", + "redirect_urls.0": "https://example.com", + "token_access_policy.access_token_ttl_in_minutes": "30", + "token_access_policy.refresh_token_ttl_in_minutes": "30", + }, + AccountID: "account_id", + ID: "integration_id", + }.ApplyAndExpectData(t, map[string]any{ + "name": "custom_integration_name", + "token_access_policy.0.access_token_ttl_in_minutes": 30, + }) +} + +func TestResourceCustomAppIntegrationDelete(t *testing.T) { + qa.ResourceFixture{ + MockAccountClientFunc: func(a *mocks.MockAccountClient) { + a.GetMockCustomAppIntegrationAPI().EXPECT().DeleteByIntegrationId(mock.Anything, "integration_id").Return(nil) + }, + Resource: ResourceCustomAppIntegration(), + AccountID: "account_id", + Delete: true, + ID: "integration_id", + }.ApplyAndExpectData(t, nil) +} diff --git a/docs/resources/custom_app_integration.md b/docs/resources/custom_app_integration.md new file mode 100644 index 0000000000..ffd2b79eb9 --- /dev/null +++ b/docs/resources/custom_app_integration.md @@ -0,0 +1,58 @@ +--- +subcategory: "Apps" +--- +# databricks_custom_app_integration Resource + +-> Initialize provider with `alias = "account"`, and `host` pointing to the account URL, like, `host = "https://accounts.cloud.databricks.com"`. Use `provider = databricks.account` for all account-level resources. + +This resource allows you to enable [custom OAuth applications](https://docs.databricks.com/en/integrations/enable-disable-oauth.html#enable-custom-oauth-applications-using-the-databricks-ui). + +## Example Usage + +```hcl +resource "databricks_custom_app_integration" "this" { + name = "custom_integration_name" + redirect_urls = ["https://example.com"] + scopes = ["all-apis"] + token_access_policy { + access_token_ttl_in_minutes = %s + refresh_token_ttl_in_minutes = 30 + } +} +``` + +## Argument Reference + +The following arguments are available: + +* `name` - (Required) Name of the custom OAuth app. Change requires a new resource. +* `confidential` - Indicates whether an OAuth client secret is required to authenticate this client. Default to `false`. Change requires a new resource. +* `redirect_urls` - List of OAuth redirect urls. +* `scopes` - OAuth scopes granted to the application. Supported scopes: `all-apis`, `sql`, `offline_access`, `openid`, `profile`, `email`. + +### token_access_policy Configuration Block (Optional) + +* `access_token_ttl_in_minutes` - access token time to live (TTL) in minutes. +* `refresh_token_ttl_in_minutes` - refresh token TTL in minutes. The TTL of refresh token cannot be lower than TTL of access token. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `integration_id` - Unique integration id for the custom OAuth app. +* `client_id` - OAuth client-id generated by Databricks +* `client_secret` - OAuth client-secret generated by the Databricks if this is a confidential OAuth app. + +## Import + +This resource can be imported by its integration ID. + +```sh +terraform import databricks_custom_app_integration.this '' +``` + +## Related Resources + +The following resources are used in the context: + +* [databricks_mws_workspaces](mws_workspaces.md) to set up Databricks workspaces. diff --git a/internal/acceptance/custom_app_integration_test.go b/internal/acceptance/custom_app_integration_test.go new file mode 100644 index 0000000000..280e5c039b --- /dev/null +++ b/internal/acceptance/custom_app_integration_test.go @@ -0,0 +1,34 @@ +package acceptance + +import ( + "fmt" + "testing" +) + +var ( + customAppIntegrationTemplate = `resource "databricks_custom_app_integration" "this" { + name = "custom_integration_name" + redirect_urls = ["https://example.com"] + scopes = ["all-apis"] + token_access_policy { + access_token_ttl_in_minutes = %s + refresh_token_ttl_in_minutes = 30 + } + }` +) + +func TestMwsAccCustomAppIntegrationCreate(t *testing.T) { + loadAccountEnv(t) + AccountLevel(t, Step{ + Template: fmt.Sprintf(customAppIntegrationTemplate, "30"), + }) +} + +func TestMwsAccCustomAppIntegrationUpdate(t *testing.T) { + loadAccountEnv(t) + AccountLevel(t, Step{ + Template: fmt.Sprintf(customAppIntegrationTemplate, "30"), + }, Step{ + Template: fmt.Sprintf(customAppIntegrationTemplate, "15"), + }) +} diff --git a/internal/providers/sdkv2/sdkv2.go b/internal/providers/sdkv2/sdkv2.go index 7c90851314..8136901ddf 100644 --- a/internal/providers/sdkv2/sdkv2.go +++ b/internal/providers/sdkv2/sdkv2.go @@ -23,6 +23,7 @@ import ( "github.com/databricks/databricks-sdk-go/useragent" "github.com/databricks/terraform-provider-databricks/access" + "github.com/databricks/terraform-provider-databricks/apps" "github.com/databricks/terraform-provider-databricks/aws" "github.com/databricks/terraform-provider-databricks/catalog" "github.com/databricks/terraform-provider-databricks/clusters" @@ -137,6 +138,7 @@ func DatabricksProvider() *schema.Provider { "databricks_budget": finops.ResourceBudget().ToResource(), "databricks_catalog": catalog.ResourceCatalog().ToResource(), "databricks_catalog_workspace_binding": catalog.ResourceCatalogWorkspaceBinding().ToResource(), + "databricks_custom_app_integration": apps.ResourceCustomAppIntegration().ToResource(), "databricks_connection": catalog.ResourceConnection().ToResource(), "databricks_cluster": clusters.ResourceCluster().ToResource(), "databricks_cluster_policy": policies.ResourceClusterPolicy().ToResource(),