diff --git a/.changelog/3547.txt b/.changelog/3547.txt new file mode 100644 index 00000000000..e24167b6734 --- /dev/null +++ b/.changelog/3547.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +resource/teams-certificate: add teams certificates to Gateway and Gateway account configurations +``` diff --git a/internal/sdkv2provider/provider.go b/internal/sdkv2provider/provider.go index 305e9e0c9a2..225f8998f02 100644 --- a/internal/sdkv2provider/provider.go +++ b/internal/sdkv2provider/provider.go @@ -263,6 +263,7 @@ func New(version string) func() *schema.Provider { "cloudflare_static_route": resourceCloudflareStaticRoute(), "cloudflare_bot_management": resourceCloudflareBotManagement(), "cloudflare_teams_account": resourceCloudflareTeamsAccount(), + "cloudflare_zero_trust_gateway_certificate": resourceCloudflareTeamsCertificate(), "cloudflare_teams_list": resourceCloudflareTeamsList(), "cloudflare_teams_location": resourceCloudflareTeamsLocation(), "cloudflare_teams_proxy_endpoint": resourceCloudflareTeamsProxyEndpoint(), diff --git a/internal/sdkv2provider/resource_cloudflare_teams_accounts.go b/internal/sdkv2provider/resource_cloudflare_teams_accounts.go index 0be84556390..10f312006d4 100644 --- a/internal/sdkv2provider/resource_cloudflare_teams_accounts.go +++ b/internal/sdkv2provider/resource_cloudflare_teams_accounts.go @@ -108,6 +108,12 @@ func resourceCloudflareTeamsAccountRead(ctx context.Context, d *schema.ResourceD } } + if configuration.Settings.Certificate != nil { + if err := d.Set("certificate", flattenCertificateConfig(configuration.Settings.Certificate)); err != nil { + return diag.FromErr(fmt.Errorf("error parsing account custom certificate config: %w", err)) + } + } + logSettings, err := client.TeamsAccountLoggingConfiguration(ctx, accountID) if err != nil { return diag.FromErr(fmt.Errorf("error finding Teams Account log settings %q: %w", d.Id(), err)) @@ -164,6 +170,7 @@ func resourceCloudflareTeamsAccountUpdate(ctx context.Context, d *schema.Resourc antivirusConfig := inflateAntivirusConfig(d.Get("antivirus")) extendedEmailMatchingConfig := inflateExtendedEmailMatchingConfig(d.Get("extended_email_matching")) customCertificateConfig := inflateCustomCertificateConfig(d.Get("custom_certificate")) + certificateConfig := inflateCertificateConfig(d.Get("certificate")) loggingConfig := inflateLoggingSettings(d.Get("logging")) deviceConfig := inflateDeviceSettings(d.Get("proxy")) payloadLogSettings := inflatePayloadLogSettings(d.Get("payload_log")) @@ -175,9 +182,14 @@ func resourceCloudflareTeamsAccountUpdate(ctx context.Context, d *schema.Resourc FIPS: fipsConfig, BodyScanning: bodyScanningConfig, ExtendedEmailMatching: extendedEmailMatchingConfig, - CustomCertificate: customCertificateConfig, }, } + if customCertificateConfig != nil { + updatedTeamsAccount.Settings.CustomCertificate = customCertificateConfig + } + if certificateConfig != nil { + updatedTeamsAccount.Settings.Certificate = certificateConfig + } //nolint:staticcheck tlsDecrypt, ok := d.GetOkExists("tls_decrypt_enabled") @@ -529,3 +541,21 @@ func inflateCustomCertificateConfig(config interface{}) *cloudflare.TeamsCustomC ID: configMap["id"].(string), } } + +func flattenCertificateConfig(config *cloudflare.TeamsCertificateSetting) []interface{} { + return []interface{}{map[string]interface{}{ + "id": *&config.ID, + }} +} + +func inflateCertificateConfig(config interface{}) *cloudflare.TeamsCertificateSetting { + list := config.([]interface{}) + if len(list) != 1 { + return nil + } + + configMap := list[0].(map[string]interface{}) + return &cloudflare.TeamsCertificateSetting{ + ID: configMap["id"].(string), + } +} diff --git a/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go b/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go index 73c7dfc8bda..7b284dfc7c4 100644 --- a/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go +++ b/internal/sdkv2provider/resource_cloudflare_teams_accounts_test.go @@ -30,7 +30,7 @@ func TestAccCloudflareTeamsAccounts_ConfigurationBasic(t *testing.T) { Config: testAccCloudflareTeamsAccountBasic(rnd, accountID), Check: resource.ComposeTestCheckFunc( resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), - resource.TestCheckResourceAttr(name, "custom_certificate.0.enabled", "false"), + resource.TestCheckResourceAttr(name, "certificate.id", "fake-id"), resource.TestCheckResourceAttr(name, "tls_decrypt_enabled", "true"), resource.TestCheckResourceAttr(name, "protocol_detection_enabled", "true"), resource.TestCheckResourceAttr(name, "activity_log_enabled", "true"), @@ -138,8 +138,8 @@ resource "cloudflare_teams_account" "%[1]s" { extended_email_matching { enabled = true } - custom_certificate { - enabled = false + certificate { + id = "fake-id" } } `, rnd, accountID) diff --git a/internal/sdkv2provider/resource_cloudflare_teams_certificate_test.go b/internal/sdkv2provider/resource_cloudflare_teams_certificate_test.go new file mode 100644 index 00000000000..c01dfafa6ee --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_teams_certificate_test.go @@ -0,0 +1,90 @@ +package sdkv2provider + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccCloudflareTeamsCertificate_Basic(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") != "" { + t.Setenv("CLOUDFLARE_API_TOKEN", "") + } + + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_zero_trust_gateway_certificate.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareTeamsCertificateDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareTeamsCertificateCustomBasic(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "id", rnd), + resource.TestCheckResourceAttr(name, "binding_status", "inactive"), + resource.TestCheckResourceAttr(name, "gateway_managed", "false"), + ), + }, + { + Config: testAccCloudflareTeamsCertificateManagedBasic(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, consts.AccountIDSchemaKey, accountID), + resource.TestCheckResourceAttr(name, "id", rnd), + resource.TestCheckResourceAttr(name, "binding_status", "inactive"), + resource.TestCheckResourceAttr(name, "custom", "false"), + ), + }, + }, + }) +} + +func testAccCloudflareTeamsCertificateCustomBasic(rnd, accountID string) string { + return fmt.Sprintf(` +resource "cloudflare_zero_trust_gateway_certificate" "%[1]s" { + account_id = "%[2]s" + custom = true + id = "%[1]s" +} +`, rnd, accountID) +} + +func testAccCloudflareTeamsCertificateManagedBasic(rnd, accountID string) string { + return fmt.Sprintf(` +resource "cloudflare_zero_trust_gateway_certificate" "%[1]s" { + account_id = "%[2]s" + gateway_managed = true +} +`, rnd, accountID) +} + +func testAccCheckCloudflareTeamsCertificateDestroy(s *terraform.State) error { + client := testAccProvider.Meta().(*cloudflare.API) + + for _, rs := range s.RootModule().Resources { + if rs.Type != "cloudflare_zero_trust_gateway_certificate" { + continue + } + + accountID = rs.Primary.Attributes[consts.AccountIDSchemaKey] + _, err := client.TeamsCertificate(context.Background(), accountID, rs.Primary.ID) + if err == nil { + return fmt.Errorf("Teams Certificate still exists") + } + } + + return nil +} diff --git a/internal/sdkv2provider/resource_cloudflare_teams_certificates.go b/internal/sdkv2provider/resource_cloudflare_teams_certificates.go new file mode 100644 index 00000000000..01e50152dac --- /dev/null +++ b/internal/sdkv2provider/resource_cloudflare_teams_certificates.go @@ -0,0 +1,150 @@ +package sdkv2provider + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/MakeNowJust/heredoc/v2" + cloudflare "github.com/cloudflare/cloudflare-go" + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareTeamsCertificate() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareTeamsCertificateSchema(), + CreateContext: resourceCloudflareTeamsCertificateCreate, + UpdateContext: resourceCloudflareTeamsCertificateUpdate, + ReadContext: resourceCloudflareTeamsCertificateRead, + DeleteContext: resourceCloudflareTeamsCertificateDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceCloudflareTeamsCertificateImport, + }, + Description: heredoc.Doc(` + Provides a Cloudflare Teams Gateway Certificate resource. A Teams Certificate can + be specified for Gateway TLS interception and block pages. + `), + } +} + +func resourceCloudflareTeamsCertificateCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + if d.Get("gateway_managed").(bool) { + newTeamsCertificate := cloudflare.TeamsCertificateCreateRequest{ + ValidityPeriodDays: d.Get("validity_period_days").(int), + } + + tflog.Debug(ctx, fmt.Sprintf("Creating Cloudflare Teams Certificate from struct: %+v", newTeamsCertificate)) + + certificate, err := client.TeamsGenerateCertificate(ctx, accountID, newTeamsCertificate) + if err != nil { + return diag.FromErr(fmt.Errorf("error creating Teams Certificate for account %q: %w", accountID, err)) + } + d.SetId(certificate.ID) + } + + if d.Get("activate").(bool) { + certificate, err := client.TeamsActivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error activating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + d.Set("binding_status", certificate.BindingStatus) + d.Set("qs_pack_id", certificate.QsPackId) + } + + return resourceCloudflareTeamsCertificateRead(ctx, d, meta) +} + +func resourceCloudflareTeamsCertificateUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + if d.Get("activate").(bool) { + certificate, err := client.TeamsActivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error activating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + d.Set("binding_status", certificate.BindingStatus) + d.Set("qs_pack_id", certificate.QsPackId) + } else { + certificate, err := client.TeamsDeactivateCertificate(ctx, accountID, d.Get("id").(string)) + if err != nil { + return diag.FromErr(fmt.Errorf("error deactivating Teams Certificate with id %q for account %q: %w", d.Get("id"), accountID, err)) + } + d.Set("binding_status", certificate.BindingStatus) + d.Set("qs_pack_id", certificate.QsPackId) + } + + return resourceCloudflareTeamsCertificateRead(ctx, d, meta) +} + +func resourceCloudflareTeamsCertificateRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + Certificate, err := client.TeamsCertificate(ctx, accountID, d.Id()) + + if err != nil { + var notFoundError *cloudflare.NotFoundError + if errors.As(err, ¬FoundError) { + tflog.Info(ctx, fmt.Sprintf("Teams Certificate %s no longer exists", d.Id())) + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("error finding Teams Certificate %q: %w", d.Id(), err)) + } + + d.Set("id", Certificate.ID) + d.Set("enabled", Certificate.Enabled) + d.Set("binding_status", Certificate.BindingStatus) + d.Set("qs_pack_id", Certificate.QsPackId) + d.Set("type", Certificate.Type) + d.Set("uploaded_on", Certificate.UploadedOn.Format(time.RFC3339Nano)) + d.Set("created_at", Certificate.CreatedAt.Format(time.RFC3339Nano)) + d.Set("expires_on", Certificate.ExpiresOn.Format(time.RFC3339Nano)) + + return nil +} + +func resourceCloudflareTeamsCertificateDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + certID := d.Id() + accountID := d.Get(consts.AccountIDSchemaKey).(string) + + tflog.Debug(ctx, fmt.Sprintf("Deleting Cloudflare Teams Certificate using ID: %s", certID)) + + err := client.TeamsDeleteCertificate(ctx, accountID, certID) + if err != nil { + return diag.FromErr(fmt.Errorf("error deleting Teams Certificate for account %q: %w", accountID, err)) + } + + resourceCloudflareTeamsCertificateRead(ctx, d, meta) + + return nil +} + +func resourceCloudflareTeamsCertificateImport(ctx context.Context, 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/teamsCertificateID\"", d.Id()) + } + + accountID, teamsCertificateID := attributes[0], attributes[1] + + tflog.Debug(ctx, fmt.Sprintf("Importing Cloudflare Teams Certificate: id %s for account %s", teamsCertificateID, accountID)) + + d.Set(consts.AccountIDSchemaKey, accountID) + d.SetId(teamsCertificateID) + + resourceCloudflareTeamsCertificateRead(ctx, d, meta) + + return []*schema.ResourceData{d}, nil +} diff --git a/internal/sdkv2provider/schema_cloudflare_teams_accounts.go b/internal/sdkv2provider/schema_cloudflare_teams_accounts.go index e4c71350604..5c104b8faea 100644 --- a/internal/sdkv2provider/schema_cloudflare_teams_accounts.go +++ b/internal/sdkv2provider/schema_cloudflare_teams_accounts.go @@ -124,12 +124,23 @@ func resourceCloudflareTeamsAccountSchema() map[string]*schema.Schema { }, }, "custom_certificate": { + Type: schema.TypeList, + MaxItems: 1, + Optional: true, + Description: "Configuration for custom certificates / BYO-PKI.", + Deprecated: "Use `certificate` instead. Continuing to use custom_certificate may result in inconsistent configuration.", + ConflictsWith: []string{"certificate"}, + Elem: &schema.Resource{ + Schema: customCertificateSchema, + }, + }, + "certificate": { Type: schema.TypeList, MaxItems: 1, Optional: true, - Description: "Configuration for custom certificates / BYO-PKI.", + Description: "Configuration for TLS interception certificate. This will be required starting Feb 2025.", Elem: &schema.Resource{ - Schema: customCertificateSchema, + Schema: certificateSettingSchema, }, }, } @@ -347,3 +358,11 @@ var customCertificateSchema = map[string]*schema.Schema{ Computed: true, }, } + +var certificateSettingSchema = map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Required: true, + Description: "ID of certificate for TLS interception.", + }, +} diff --git a/internal/sdkv2provider/schema_cloudflare_teams_certificates.go b/internal/sdkv2provider/schema_cloudflare_teams_certificates.go new file mode 100644 index 00000000000..fb81640fbde --- /dev/null +++ b/internal/sdkv2provider/schema_cloudflare_teams_certificates.go @@ -0,0 +1,82 @@ +package sdkv2provider + +import ( + "fmt" + + "github.com/cloudflare/terraform-provider-cloudflare/internal/consts" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceCloudflareTeamsCertificateSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + consts.AccountIDSchemaKey: { + Description: consts.AccountIDSchemaDescription, + Type: schema.TypeString, + Required: true, + }, + "custom": { + Type: schema.TypeBool, + Optional: true, + Description: "The type of certificate (custom or Gateway-managed)", + ExactlyOneOf: []string{"custom", "gateway_managed"}, + }, + "gateway_managed": { + Type: schema.TypeBool, + Optional: true, + Description: "The type of certificate (custom or Gateway-managed)", + ExactlyOneOf: []string{"custom", "gateway_managed"}, + }, + "id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "Certificate UUID. Computed for Gateway-managed certificates.", + RequiredWith: []string{"custom"}, + ConflictsWith: []string{"gateway_managed"}, + }, + "validity_period_days": { + Type: schema.TypeInt, + Optional: true, + Description: "Number of days the generated certificate will be valid, minimum 1 day and maximum 30 years. Defaults to 5 years.", + ValidateFunc: validation.IntBetween(1, 10950), + Default: 1826, + RequiredWith: []string{"gateway_managed"}, + ConflictsWith: []string{"custom"}, + ForceNew: true, + }, + "activate": { + Type: schema.TypeBool, + Optional: true, + Description: "Whether or not to activate a certificate. A certificate must be activated to use in Gateway certificate settings", + Default: false, + }, + "enabled": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the certificate is in use by Gateway for TLS interception and the block page", + }, + "binding_status": { + Type: schema.TypeString, + Computed: true, + Description: fmt.Sprintf("The deployment status of the certificate on the edge %s", renderAvailableDocumentationValuesStringSlice([]string{"IP", "SERIAL", "URL", "DOMAIN", "EMAIL"})), + }, + "qs_pack_id": { + Type: schema.TypeString, + Computed: true, + }, + + "uploaded_on": { + Type: schema.TypeString, + Computed: true, + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + }, + "expires_on": { + Type: schema.TypeString, + Computed: true, + }, + } +}