diff --git a/cloudflare/provider.go b/cloudflare/provider.go index ccad186c9d..d89230d338 100644 --- a/cloudflare/provider.go +++ b/cloudflare/provider.go @@ -142,6 +142,7 @@ func Provider() *schema.Provider { "cloudflare_custom_pages": resourceCloudflareCustomPages(), "cloudflare_custom_ssl": resourceCloudflareCustomSsl(), "cloudflare_device_posture_rule": resourceCloudflareDevicePostureRule(), + "cloudflare_device_posture_integration": resourceCloudflareDevicePostureIntegration(), "cloudflare_filter": resourceCloudflareFilter(), "cloudflare_firewall_rule": resourceCloudflareFirewallRule(), "cloudflare_healthcheck": resourceCloudflareHealthcheck(), diff --git a/cloudflare/resource_cloudflare_device_posture_integration.go b/cloudflare/resource_cloudflare_device_posture_integration.go new file mode 100644 index 0000000000..6e93d6b978 --- /dev/null +++ b/cloudflare/resource_cloudflare_device_posture_integration.go @@ -0,0 +1,185 @@ +package cloudflare + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +const ws1 = "workspace_one" + +func resourceCloudflareDevicePostureIntegration() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareDevicePostureIntegrationSchema(), + Create: resourceCloudflareDevicePostureIntegrationCreate, + Read: resourceCloudflareDevicePostureIntegrationRead, + Update: resourceCloudflareDevicePostureIntegrationUpdate, + Delete: resourceCloudflareDevicePostureIntegrationDelete, + Importer: &schema.ResourceImporter{ + State: resourceCloudflareDevicePostureIntegrationImport, + }, + } +} + +func resourceCloudflareDevicePostureIntegrationCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*cloudflare.API) + accountID := d.Get("account_id").(string) + + newDevicePostureIntegration := cloudflare.DevicePostureIntegration{ + Name: d.Get("name").(string), + Type: d.Get("type").(string), + Interval: d.Get("interval").(string), + } + + err := setDevicePostureIntegrationConfig(&newDevicePostureIntegration, d) + if err != nil { + return fmt.Errorf("error creating Device Posture integration with provided config: %s", err) + } + fmt.Printf("[DEBUG] Creating Cloudflare Device Posture Integration from struct: %+v\n", newDevicePostureIntegration) + + // The API does not return the client_secret so it must be stored in the state func on resource create. + savedSecret := newDevicePostureIntegration.Config.ClientSecret + + newDevicePostureIntegration, err = client.CreateDevicePostureIntegration(context.Background(), accountID, newDevicePostureIntegration) + if err != nil { + return fmt.Errorf("error creating Device Posture Rule for account %q: %s %+v", accountID, err, newDevicePostureIntegration) + } + + d.SetId(newDevicePostureIntegration.IntegrationID) + + return devicePostureIntegrationReadHelper(d, meta, savedSecret) +} + +func resourceCloudflareDevicePostureIntegrationRead(d *schema.ResourceData, meta interface{}) error { + // Client secret is always read from the local state. + secret, _ := d.Get("config.0.client_secret").(string) + return devicePostureIntegrationReadHelper(d, meta, secret) +} + +func devicePostureIntegrationReadHelper(d *schema.ResourceData, meta interface{}, secret string) error { + client := meta.(*cloudflare.API) + accountID := d.Get("account_id").(string) + + devicePostureIntegration, err := client.DevicePostureIntegration(context.Background(), accountID, d.Id()) + if err != nil { + if strings.Contains(err.Error(), "HTTP status 404") { + log.Printf("[INFO] Device posture integration %s no longer exists", d.Id()) + d.SetId("") + return nil + } + return fmt.Errorf("error finding device posture integration %q: %s", d.Id(), err) + } + + devicePostureIntegration.Config.ClientSecret = secret + d.Set("name", devicePostureIntegration.Name) + d.Set("type", devicePostureIntegration.Type) + d.Set("interval", devicePostureIntegration.Interval) + d.Set("config", convertIntegrationConfigToSchema(devicePostureIntegration.Config)) + + return nil +} + +func resourceCloudflareDevicePostureIntegrationUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*cloudflare.API) + accountID := d.Get("account_id").(string) + + updatedDevicePostureIntegration := cloudflare.DevicePostureIntegration{ + IntegrationID: d.Id(), + Name: d.Get("name").(string), + Type: d.Get("type").(string), + Interval: d.Get("interval").(string), + } + + err := setDevicePostureIntegrationConfig(&updatedDevicePostureIntegration, d) + if err != nil { + return fmt.Errorf("error creating Device Posture Rule with provided match input: %s", err) + } + + log.Printf("[DEBUG] Updating Cloudflare device posture integration from struct: %+v", updatedDevicePostureIntegration) + + devicePostureIntegration, err := client.UpdateDevicePostureIntegration(context.Background(), accountID, updatedDevicePostureIntegration) + if err != nil { + return fmt.Errorf("error updating device posture integration for account %q: %s", accountID, err) + } + + if devicePostureIntegration.IntegrationID == "" { + return fmt.Errorf("failed to find device posture integration_id in update response; resource was empty") + } + + return resourceCloudflareDevicePostureIntegrationRead(d, meta) +} + +func resourceCloudflareDevicePostureIntegrationDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*cloudflare.API) + appID := d.Id() + accountID := d.Get("account_id").(string) + + log.Printf("[DEBUG] Deleting Cloudflare device posture integration using ID: %s", appID) + + err := client.DeleteDevicePostureIntegration(context.Background(), accountID, appID) + if err != nil { + return fmt.Errorf("error deleting Device Posture Rule for account %q: %s", accountID, err) + } + + resourceCloudflareDevicePostureIntegrationRead(d, meta) + + return nil +} + +func resourceCloudflareDevicePostureIntegrationImport(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + attributes := strings.SplitN(d.Id(), "/", 2) + + if len(attributes) != 2 { + return nil, fmt.Errorf("invalid id (\"%s\") specified, should be in format \"accountID/devicePostureIntegrationID\"", d.Id()) + } + + accountID, devicePostureIntegrationID := attributes[0], attributes[1] + + log.Printf("[DEBUG] Importing Cloudflare device posture integration: id %s for account %s", devicePostureIntegrationID, accountID) + + d.Set("account_id", accountID) + d.SetId(devicePostureIntegrationID) + + resourceCloudflareDevicePostureIntegrationRead(d, meta) + + return []*schema.ResourceData{d}, nil +} + +func setDevicePostureIntegrationConfig(integration *cloudflare.DevicePostureIntegration, d *schema.ResourceData) error { + if _, ok := d.GetOk("config"); ok { + config := cloudflare.DevicePostureIntegrationConfig{} + switch integration.Type { + case ws1: + if config.ClientID, ok = d.Get("config.0.client_id").(string); !ok { + return fmt.Errorf("client_id is a string") + } + if config.ClientSecret, ok = d.Get("config.0.client_secret").(string); !ok { + return fmt.Errorf("client_secret is a string") + } + if config.AuthUrl, ok = d.Get("config.0.auth_url").(string); !ok { + return fmt.Errorf("auth_url is a string") + } + if config.ApiUrl, ok = d.Get("config.0.api_url").(string); !ok { + return fmt.Errorf("api_url is a string") + } + integration.Config = config + default: + return fmt.Errorf("unsupported integration type:%s", integration.Type) + } + } + return nil +} + +func convertIntegrationConfigToSchema(input cloudflare.DevicePostureIntegrationConfig) []interface{} { + m := map[string]interface{}{ + "client_id": input.ClientID, + "client_secret": input.ClientSecret, + "auth_url": input.AuthUrl, + "api_url": input.ApiUrl, + } + return []interface{}{m} +} diff --git a/cloudflare/resource_cloudflare_device_posture_integration_test.go b/cloudflare/resource_cloudflare_device_posture_integration_test.go new file mode 100644 index 0000000000..8eaec4ed11 --- /dev/null +++ b/cloudflare/resource_cloudflare_device_posture_integration_test.go @@ -0,0 +1,83 @@ +package cloudflare + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccCloudflareDevicePostureIntegrationCreate(t *testing.T) { + // Temporarily unset CLOUDFLARE_API_TOKEN if it is set as the Access + // service does not yet support the API tokens and it results in + // misleading state error messages. + if os.Getenv("CLOUDFLARE_API_TOKEN") != "" { + defer func(apiToken string) { + os.Setenv("CLOUDFLARE_API_TOKEN", apiToken) + }(os.Getenv("CLOUDFLARE_API_TOKEN")) + os.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_device_posture_integration.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccessAccPreCheck(t) + }, + Providers: testAccProviders, + CheckDestroy: testAccCheckCloudflareDevicePostureIntegrationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareDevicePostureIntegration(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "account_id", accountID), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "type", "workspace_one"), + resource.TestCheckResourceAttr(name, "interval", "24h"), + resource.TestCheckResourceAttr(name, "config.0.auth_url", "https://test.uemauth.vmwservices.com/connect/token"), + resource.TestCheckResourceAttr(name, "config.0.api_url", "https://example.com/api-url"), + resource.TestCheckResourceAttr(name, "config.0.client_id", "client-id"), + ), + }, + }, + }) +} + +func testAccCloudflareDevicePostureIntegration(rnd, accountID string) string { + return fmt.Sprintf(` +resource "cloudflare_device_posture_integration" "%[1]s" { + account_id = "%[2]s" + name = "%[1]s" + type = "workspace_one" + interval = "24h" + config { + api_url = "https://example.com/api-url" + auth_url = "https://test.uemauth.vmwservices.com/connect/token" + client_id = "client-id" + client_secret = "client-secret" + } +} +`, rnd, accountID) +} + +func testAccCheckCloudflareDevicePostureIntegrationDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*cloudflare.API) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_device_posture_integration" { + continue + } + + _, err := client.DevicePostureIntegration(context.Background(), rs.Primary.Attributes["account_id"], rs.Primary.ID) + if err == nil { + return fmt.Errorf("Device Posture Integration still exists") + } + } + + return nil +} diff --git a/cloudflare/resource_cloudflare_device_posture_rule.go b/cloudflare/resource_cloudflare_device_posture_rule.go index 5f9620f054..eb0eab1e9e 100644 --- a/cloudflare/resource_cloudflare_device_posture_rule.go +++ b/cloudflare/resource_cloudflare_device_posture_rule.go @@ -181,6 +181,12 @@ func setDevicePostureRuleInput(rule *cloudflare.DevicePostureRule, d *schema.Res if domain, ok := d.GetOk("input.0.domain"); ok { input.Domain = domain.(string) } + if complianceStatus, ok := d.GetOk("input.0.compliance_status"); ok { + input.ComplianceStatus = complianceStatus.(string) + } + if connectionID, ok := d.GetOk("input.0.connection_id"); ok { + input.ConnectionID = connectionID.(string) + } rule.Input = input } } @@ -218,17 +224,19 @@ func convertMatchToSchema(matches []cloudflare.DevicePostureRuleMatch) []map[str func convertInputToSchema(input cloudflare.DevicePostureRuleInput) []map[string]interface{} { m := map[string]interface{}{ - "id": input.ID, - "path": input.Path, - "exists": input.Exists, - "thumbprint": input.Thumbprint, - "sha256": input.Sha256, - "running": input.Running, - "require_all": input.RequireAll, - "enabled": input.Enabled, - "version": input.Version, - "operator": input.Operator, - "domain": input.Domain, + "id": input.ID, + "path": input.Path, + "exists": input.Exists, + "thumbprint": input.Thumbprint, + "sha256": input.Sha256, + "running": input.Running, + "require_all": input.RequireAll, + "enabled": input.Enabled, + "version": input.Version, + "operator": input.Operator, + "domain": input.Domain, + "compliance_status": input.ComplianceStatus, + "connection_id": input.ConnectionID, } return []map[string]interface{}{m} diff --git a/cloudflare/schema_cloudflare_device_posture_integration.go b/cloudflare/schema_cloudflare_device_posture_integration.go new file mode 100644 index 0000000000..65cebd8049 --- /dev/null +++ b/cloudflare/schema_cloudflare_device_posture_integration.go @@ -0,0 +1,63 @@ +package cloudflare + +import ( + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceCloudflareDevicePostureIntegrationSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "account_id": { + Type: schema.TypeString, + Required: true, + }, + "id": { + Type: schema.TypeString, + Computed: true, + }, + "name": { + Type: schema.TypeString, + Required: true, + }, + "type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{ws1}, false), + }, + "identifier": { + Type: schema.TypeString, + Optional: true, + }, + "interval": { + Type: schema.TypeString, + Optional: true, + }, + "config": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "auth_url": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.IsURLWithHTTPS, + }, + "api_url": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.IsURLWithHTTPS, + }, + "client_id": { + Type: schema.TypeString, + Optional: true, + }, + "client_secret": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + }, + }, + }, + }, + } +} diff --git a/cloudflare/schema_cloudflare_device_posture_rule.go b/cloudflare/schema_cloudflare_device_posture_rule.go index 7c1e2a0fd0..4712fd57aa 100644 --- a/cloudflare/schema_cloudflare_device_posture_rule.go +++ b/cloudflare/schema_cloudflare_device_posture_rule.go @@ -14,7 +14,7 @@ func resourceCloudflareDevicePostureRuleSchema() map[string]*schema.Schema { "type": { Type: schema.TypeString, Required: true, - ValidateFunc: validation.StringInSlice([]string{"serial_number", "file", "application", "gateway", "warp", "domain_joined", "os_version", "disk_encryption", "firewall"}, false), + ValidateFunc: validation.StringInSlice([]string{"serial_number", "file", "application", "gateway", "warp", "domain_joined", "os_version", "disk_encryption", "firewall", "workspace_one"}, false), }, "name": { Type: schema.TypeString, @@ -107,6 +107,17 @@ func resourceCloudflareDevicePostureRuleSchema() map[string]*schema.Schema { Optional: true, Description: "The domain that the client must join.", }, + "connection_id": { + Type: schema.TypeString, + Optional: true, + Description: "The workspace one connection id.", + }, + "compliance_status": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringInSlice([]string{"compliant", "noncompliant"}, true), + Description: "The workspace one device compliance status.", + }, }, }, }, diff --git a/website/docs/r/device_posture_integration.html.markdown b/website/docs/r/device_posture_integration.html.markdown new file mode 100644 index 0000000000..d45323a107 --- /dev/null +++ b/website/docs/r/device_posture_integration.html.markdown @@ -0,0 +1,63 @@ +--- +layout: "cloudflare" +page_title: "Cloudflare: cloudflare_device_posture_integration" +sidebar_current: "docs-cloudflare-resource-device-posture-integration" +description: |- + Provides a Cloudflare Device Posture Integration resource. +--- + +# cloudflare_device_posture_integration + +Provides a Cloudflare Device Posture Integration resource. Device posture integrations configure third-party data providers for device posture rules. + +## Example Usage + +```hcl +resource "cloudflare_device_posture_integration" "third_party_devices_posture_integration" { + account_id = "1d5fdc9e88c8a8c4518b068cd94331fe" + name = "Device posture integration" + type = "workspace_one" + interval = "24h" + config { + api_url = "https://example.com/api" + auth_url = "https://example.com/connect/token" + client_id = "client-id" + client_secret = "client-secret" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `account_id` - (Required) The account to which the device posture integration should be added. +* `name` - (Optional) Name of the device posture integration. +* `type` - (Required) The device posture integration type. Valid values are `workspace_one`. +* `interval` - (Optional) Indicates the frequency with which to poll the third-party API. + Must be in the format `"1h"` or `"30m"`. Valid units are `h` and `m`. +* `config` - (Required) The device posture integration's connection authorization parameters. + +### Config argument + +The config structure allows the following: + +* `api_url` - (Required) The third-party API's URL. +* `auth_url` - (Required) The third-party authorization API URL. +* `client_id` - (Required) The client identifier for authenticating API calls. +* `client_secret` - (Required) The client secret for authenticating API calls. + +## Attributes Reference + +The following additional attributes are exported: + +* `id` - ID of the device posture integration. + +## Import + +Device posture integrations can be imported using a composite ID formed of account +ID and device posture integration ID. + +``` +$ terraform import cloudflare_device_posture_integration.corporate_devices cb029e245cfdd66dc8d2e570d5dd3322/0ade592a-62d6-46ab-bac8-01f47c7fa792 +```