diff --git a/.changelog/1961.txt b/.changelog/1961.txt new file mode 100644 index 0000000000..50ff5d91bf --- /dev/null +++ b/.changelog/1961.txt @@ -0,0 +1,3 @@ +```release-note:new-resource +cloudflare_access_organization +``` diff --git a/docs/resources/access_organization.md b/docs/resources/access_organization.md new file mode 100644 index 0000000000..ff94d0bd55 --- /dev/null +++ b/docs/resources/access_organization.md @@ -0,0 +1,65 @@ +--- +page_title: "cloudflare_access_organization Resource - Cloudflare" +subcategory: "" +description: |- + A Zero Trust organization defines the user login experience. +--- + +# cloudflare_access_organization (Resource) + +A Zero Trust organization defines the user login experience. + +## Example Usage + +```terraform +resource "cloudflare_access_organization" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + name = "example.cloudflareaccess.com" + auth_domain = "example.cloudflareaccess.com" + is_ui_read_only = false + + login_design { + background_color = "#ffffff" + text_color = "#000000" + logo_path = "https://example.com/logo.png" + header_text = "My header text" + footer_text = "My footer text" + } +} +``` + +## Schema + +### Required + +- `auth_domain` (String) The unique subdomain assigned to your Zero Trust organization. + +### Optional + +- `account_id` (String) The account identifier to target for the resource. Conflicts with `zone_id`. +- `is_ui_read_only` (Boolean) When set to true, this will disable all editing of Access resources via the Zero Trust Dashboard. +- `login_design` (Block List) (see [below for nested schema](#nestedblock--login_design)) +- `name` (String) The name of your Zero Trust organization. +- `zone_id` (String) The zone identifier to target for the resource. Conflicts with `account_id`. + +### Read-Only + +- `id` (String) The ID of this resource. + + +### Nested Schema for `login_design` + +Optional: + +- `background_color` (String) The background color on the login page. +- `footer_text` (String) The text at the bottom of the login page. +- `header_text` (String) The text at the top of the login page. +- `logo_path` (String) The URL of the logo on the login page. +- `text_color` (String) The text color on the login page. + +## Import + +Import is supported using the following syntax: +```shell +$ terraform import cloudflare_access_organization.example +``` diff --git a/examples/resources/cloudflare_access_organization/import.sh b/examples/resources/cloudflare_access_organization/import.sh new file mode 100644 index 0000000000..5024f7c658 --- /dev/null +++ b/examples/resources/cloudflare_access_organization/import.sh @@ -0,0 +1 @@ +$ terraform import cloudflare_access_organization.example diff --git a/examples/resources/cloudflare_access_organization/resource.tf b/examples/resources/cloudflare_access_organization/resource.tf new file mode 100644 index 0000000000..73ccaec3dc --- /dev/null +++ b/examples/resources/cloudflare_access_organization/resource.tf @@ -0,0 +1,14 @@ +resource "cloudflare_access_organization" "example" { + account_id = "f037e56e89293a057740de681ac9abbe" + name = "example.cloudflareaccess.com" + auth_domain = "example.cloudflareaccess.com" + is_ui_read_only = false + + login_design { + background_color = "#ffffff" + text_color = "#000000" + logo_path = "https://example.com/logo.png" + header_text = "My header text" + footer_text = "My footer text" + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b774ed87d9..830bbccf95 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -196,6 +196,7 @@ func New(version string) func() *schema.Provider { "cloudflare_access_identity_provider": resourceCloudflareAccessIdentityProvider(), "cloudflare_access_keys_configuration": resourceCloudflareAccessKeysConfiguration(), "cloudflare_access_mutual_tls_certificate": resourceCloudflareAccessMutualTLSCertificate(), + "cloudflare_access_organization": resourceCloudflareAccessOrganization(), "cloudflare_access_policy": resourceCloudflareAccessPolicy(), "cloudflare_access_rule": resourceCloudflareAccessRule(), "cloudflare_access_service_token": resourceCloudflareAccessServiceToken(), diff --git a/internal/provider/resource_cloudflare_access_organization.go b/internal/provider/resource_cloudflare_access_organization.go new file mode 100644 index 0000000000..c05d88a520 --- /dev/null +++ b/internal/provider/resource_cloudflare_access_organization.go @@ -0,0 +1,118 @@ +package provider + +import ( + "context" + "errors" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +type contextKey int + +const orgAccessImportCtxKey contextKey = iota + +func resourceCloudflareAccessOrganization() *schema.Resource { + return &schema.Resource{ + Schema: resourceCloudflareAccessOrganizationSchema(), + CreateContext: resourceCloudflareAccessOrganizationCreate, + ReadContext: resourceCloudflareAccessOrganizationRead, + UpdateContext: resourceCloudflareAccessOrganizationUpdate, + DeleteContext: resourceCloudflareAccessOrganizationNoop, + Importer: &schema.ResourceImporter{ + StateContext: resourceCloudflareAccessOrganizationImport, + }, + Description: heredoc.Doc(` + A Zero Trust organization defines the user login experience. + `), + } +} + +func resourceCloudflareAccessOrganizationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + return diag.FromErr(fmt.Errorf("access organizations cannot be created and must be imported")) +} + +func resourceCloudflareAccessOrganizationNoop(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + return nil +} + +func resourceCloudflareAccessOrganizationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + + identifier, err := initIdentifier(d) + if err != nil { + return diag.FromErr(err) + } + + var organization cloudflare.AccessOrganization + if identifier.Type == AccountType { + organization, _, err = client.AccessOrganization(ctx, identifier.Value) + } else { + organization, _, err = client.ZoneLevelAccessOrganization(ctx, identifier.Value) + } + if err != nil { + return diag.FromErr(fmt.Errorf("error fetching access organization: %w", err)) + } + + d.Set("name", organization.Name) + d.Set("auth_domain", organization.AuthDomain) + d.Set("is_ui_read_only", organization.IsUIReadOnly) + + loginDesign := convertLoginDesignStructToSchema(ctx, d, &organization.LoginDesign) + if loginDesignErr := d.Set("login_design", loginDesign); loginDesignErr != nil { + return diag.FromErr(fmt.Errorf("error setting Access Organization Login Design configuration: %w", loginDesignErr)) + } + + return nil +} + +func resourceCloudflareAccessOrganizationUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*cloudflare.API) + + updatedAccessOrganization := cloudflare.AccessOrganization{ + Name: d.Get("name").(string), + AuthDomain: d.Get("auth_domain").(string), + IsUIReadOnly: cloudflare.BoolPtr(d.Get("is_ui_read_only").(bool)), + } + loginDesign := convertLoginDesignSchemaToStruct(d) + updatedAccessOrganization.LoginDesign = *loginDesign + + tflog.Debug(ctx, fmt.Sprintf("Updating Cloudflare Access Organization from struct: %+v", updatedAccessOrganization)) + + identifier, err := initIdentifier(d) + if err != nil { + return diag.FromErr(err) + } + + if identifier.Type == AccountType { + _, err = client.UpdateAccessOrganization(ctx, identifier.Value, updatedAccessOrganization) + } else { + _, err = client.UpdateZoneLevelAccessOrganization(ctx, identifier.Value, updatedAccessOrganization) + } + if err != nil { + return diag.FromErr(fmt.Errorf("error updating Access Organization for %s %q: %w", identifier.Type, identifier.Value, err)) + } + + return resourceCloudflareAccessOrganizationRead(ctx, d, meta) +} + +func resourceCloudflareAccessOrganizationImport(ctx context.Context, d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { + ctx = context.WithValue(ctx, orgAccessImportCtxKey, true) + + accountID := d.Id() + + tflog.Info(ctx, fmt.Sprintf("Importing Cloudflare Access Organization for account %s", accountID)) + + d.Set("account_id", accountID) + + readErr := resourceCloudflareAccessOrganizationRead(ctx, d, meta) + if readErr != nil { + return nil, errors.New("failed to read Access Organization state") + } + + return []*schema.ResourceData{d}, nil +} diff --git a/internal/provider/resource_cloudflare_access_organization_test.go b/internal/provider/resource_cloudflare_access_organization_test.go new file mode 100644 index 0000000000..fb8f8bb332 --- /dev/null +++ b/internal/provider/resource_cloudflare_access_organization_test.go @@ -0,0 +1,77 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccCloudflareAccessOrganization(t *testing.T) { + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_access_organization.%s", rnd) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPreCheck(t) + testAccessAccPreCheck(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAccessOrganizationConfigBasic(rnd, accountID), + ResourceName: name, + ImportState: true, + ImportStateId: accountID, + ImportStateCheck: accessOrgImportStateCheck, + }, + }, + }) +} + +func accessOrgImportStateCheck(instanceStates []*terraform.InstanceState) error { + state := instanceStates[0] + attrs := state.Attributes + + stateChecks := []struct { + field string + stateValue string + expectedValue string + }{ + {field: "ID", stateValue: state.ID, expectedValue: accountID}, + {field: "account_id", stateValue: attrs["account_id"], expectedValue: accountID}, + {field: "name", stateValue: attrs["name"], expectedValue: "terraform-cfapi.cloudflareaccess.com"}, + {field: "auth_domain", stateValue: attrs["auth_domain"], expectedValue: "terraform-cfapi.cloudflareaccess.com"}, + {field: "is_ui_read_only", stateValue: attrs["is_ui_read_only"], expectedValue: "false"}, + {field: "login_design.#", stateValue: attrs["login_design.#"], expectedValue: "1"}, + } + + for _, check := range stateChecks { + if check.stateValue != check.expectedValue { + return fmt.Errorf("%s has value %s and does not match expected value %s", check.field, check.stateValue, check.expectedValue) + } + } + + return nil +} + +func testAccCloudflareAccessOrganizationConfigBasic(rnd, accountID string) string { + return fmt.Sprintf(` + resource "cloudflare_access_organization" "%[1]s" { + account_id = "%[2]s" + name = "terraform-cfapi.cloudflareaccess.com" + auth_domain = "terraform-cfapi.cloudflareaccess.com1" + is_ui_read_only = false + + login_design { + background_color = "#FFFFFF" + text_color = "#000000" + logo_path = "https://example.com/logo.png" + header_text = "My header text" + footer_text = "My footer text" + } + } + `, rnd, accountID) +} diff --git a/internal/provider/schema_cloudflare_access_organization.go b/internal/provider/schema_cloudflare_access_organization.go new file mode 100644 index 0000000000..f5b38fc864 --- /dev/null +++ b/internal/provider/schema_cloudflare_access_organization.go @@ -0,0 +1,111 @@ +package provider + +import ( + "context" + + "github.com/cloudflare/cloudflare-go" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceCloudflareAccessOrganizationSchema() map[string]*schema.Schema { + return map[string]*schema.Schema{ + "account_id": { + Description: "The account identifier to target for the resource.", + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"zone_id"}, + }, + "zone_id": { + Description: "The zone identifier to target for the resource.", + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"account_id"}, + }, + "auth_domain": { + Description: "The unique subdomain assigned to your Zero Trust organization.", + Type: schema.TypeString, + Required: true, + }, + "name": { + Type: schema.TypeString, + Optional: true, + Description: "The name of your Zero Trust organization.", + }, + "is_ui_read_only": { + Type: schema.TypeBool, + Optional: true, + Description: "When set to true, this will disable all editing of Access resources via the Zero Trust Dashboard", + }, + "login_design": { + Type: schema.TypeList, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "background_color": { + Type: schema.TypeString, + Optional: true, + Description: "The background color on the login page", + }, + "text_color": { + Type: schema.TypeString, + Optional: true, + Description: "The text color on the login page", + }, + "logo_path": { + Type: schema.TypeString, + Optional: true, + Description: "The URL of the logo on the login page", + }, + "header_text": { + Type: schema.TypeString, + Optional: true, + Description: "The text at the top of the login page", + }, + "footer_text": { + Type: schema.TypeString, + Optional: true, + Description: "The text at the bottom of the login page", + }, + }, + }, + }, + } +} + +func convertLoginDesignSchemaToStruct(d *schema.ResourceData) *cloudflare.AccessOrganizationLoginDesign { + LoginDesign := cloudflare.AccessOrganizationLoginDesign{} + + if _, ok := d.GetOk("login_design.0"); ok { + LoginDesign.BackgroundColor = d.Get("login_design.0.background_color").(string) + LoginDesign.TextColor = d.Get("login_design.0.text_color").(string) + LoginDesign.LogoPath = d.Get("login_design.0.logo_path").(string) + LoginDesign.HeaderText = d.Get("login_design.0.header_text").(string) + LoginDesign.FooterText = d.Get("login_design.0.footer_text").(string) + } + + return &LoginDesign +} + +func convertLoginDesignStructToSchema(ctx context.Context, d *schema.ResourceData, loginDesign *cloudflare.AccessOrganizationLoginDesign) []interface{} { + var onImport bool + var ok bool + if onImport, ok = ctx.Value(orgAccessImportCtxKey).(bool); !ok { + onImport = false + } + + if _, ok := d.GetOk("login_design"); !ok && !onImport { + return []interface{}{} + } + + m := map[string]interface{}{ + "background_color": loginDesign.BackgroundColor, + "text_color": loginDesign.TextColor, + "logo_path": loginDesign.LogoPath, + "header_text": loginDesign.HeaderText, + "footer_text": loginDesign.FooterText, + } + + return []interface{}{m} +}