diff --git a/.changelog/1762.txt b/.changelog/1762.txt new file mode 100644 index 0000000000..e4a75ccea1 --- /dev/null +++ b/.changelog/1762.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/cloudflare_access_application: Add support for Saas applications +``` diff --git a/docs/resources/access_application.md b/docs/resources/access_application.md index 3bf11a0e63..d27be7f2d4 100644 --- a/docs/resources/access_application.md +++ b/docs/resources/access_application.md @@ -52,7 +52,6 @@ resource "cloudflare_access_application" "staging_app" { ### Required -- `domain` (String) The complete URL of the asset you wish to put Cloudflare Access in front of. Can include subdomains or paths. Or both. - `name` (String) Friendly name of the Access Application. ### Optional @@ -64,14 +63,16 @@ resource "cloudflare_access_application" "staging_app" { - `cors_headers` (Block List) CORS configuration for the Access Application. See below for reference structure. (see [below for nested schema](#nestedblock--cors_headers)) - `custom_deny_message` (String) Option that returns a custom error message when a user is denied access to the application. - `custom_deny_url` (String) Option that redirects to a custom URL when a user is denied access to the application. +- `domain` (String) The complete URL of the asset you wish to put Cloudflare Access in front of. Can include subdomains or paths. Or both. - `enable_binding_cookie` (Boolean) Option to provide increased security against compromised authorization tokens and CSRF attacks by requiring an additional "binding" cookie on requests. Defaults to `false`. - `http_only_cookie_attribute` (Boolean) Option to add the `HttpOnly` cookie flag to access tokens. Defaults to `true`. - `logo_url` (String) Image URL for the logo shown in the app launcher dashboard. +- `saas_app` (Block List, Max: 1) SaaS configuration for the Access Application. (see [below for nested schema](#nestedblock--saas_app)) - `same_site_cookie_attribute` (String) Defines the same-site cookie setting for access tokens. Available values: `none`, `lax`, `strict`. - `service_auth_401_redirect` (Boolean) Option to return a 401 status code in service authentication rules on failed requests. Defaults to `false`. - `session_duration` (String) How often a user will be forced to re-authorise. Must be in the format `48h` or `2h45m`. Defaults to `24h`. - `skip_interstitial` (Boolean) Option to skip the authorization interstitial when using the CLI. Defaults to `false`. -- `type` (String) The application type. Available values: `self_hosted`, `ssh`, `vnc`, `file`. Defaults to `self_hosted`. +- `type` (String) The application type. Available values: `self_hosted`, `saas`, `ssh`, `vnc`, `file`. Defaults to `self_hosted`. - `zone_id` (String) The zone identifier to target for the resource. Conflicts with `account_id`. ### Read-Only @@ -93,6 +94,19 @@ Optional: - `allowed_origins` (Set of String) List of origins permitted to make CORS requests. - `max_age` (Number) The maximum time a preflight request will be cached. + + +### Nested Schema for `saas_app` + +Required: + +- `consumer_service_url` (String) The service provider's endpoint that is responsible for receiving and parsing a SAML assertion. +- `sp_entity_id` (String) A globally unique name for an identity or service provider. + +Optional: + +- `name_id_format` (String) The format of the name identifier sent to the SaaS application. Defaults to `email`. + ## Import Import is supported using the following syntax: diff --git a/docs/resources/api_token.md b/docs/resources/api_token.md index 3b789f71ad..03e8f7c2b0 100644 --- a/docs/resources/api_token.md +++ b/docs/resources/api_token.md @@ -30,6 +30,8 @@ resource "cloudflare_api_token" "api_token_create" { resources = { "com.cloudflare.api.user.${var.user_id}" = "*" } + not_before = "2018-07-01T05:20:00Z" + expires_on = "2020-01-01T00:00:00Z" } condition { @@ -144,6 +146,8 @@ resource "cloudflare_api_token" "dns_edit_all_account" { ### Optional - `condition` (Block List, Max: 1) Conditions under which the token should be considered valid. (see [below for nested schema](#nestedblock--condition)) +- `expires_on` (String) The expiration time on or after which the token MUST NOT be accepted for processing. +- `not_before` (String) The time before which the token MUST NOT be accepted for processing. ### Read-Only diff --git a/internal/provider/resource_cloudflare_access_application.go b/internal/provider/resource_cloudflare_access_application.go index 854a26055f..b495f856dd 100644 --- a/internal/provider/resource_cloudflare_access_application.go +++ b/internal/provider/resource_cloudflare_access_application.go @@ -64,6 +64,10 @@ func resourceCloudflareAccessApplicationCreate(ctx context.Context, d *schema.Re newAccessApplication.CorsHeaders = CORSConfig } + if _, ok := d.GetOk("saas_app"); ok { + newAccessApplication.SaasApplication = convertSaasSchemaToStruct(d) + } + tflog.Debug(ctx, fmt.Sprintf("Creating Cloudflare Access Application from struct: %+v", newAccessApplication)) identifier, err := initIdentifier(d) @@ -133,6 +137,11 @@ func resourceCloudflareAccessApplicationRead(ctx context.Context, d *schema.Reso return diag.FromErr(fmt.Errorf("error setting Access Application CORS header configuration: %w", corsConfigErr)) } + saasConfig := convertSaasStructToSchema(d, accessApplication.SaasApplication) + if saasConfigErr := d.Set("saas_app", saasConfig); saasConfigErr != nil { + return diag.FromErr(fmt.Errorf("error setting Access Application SaaS app configuration: %w", saasConfigErr)) + } + return nil } @@ -160,6 +169,10 @@ func resourceCloudflareAccessApplicationUpdate(ctx context.Context, d *schema.Re ServiceAuth401Redirect: d.Get("service_auth_401_redirect").(bool), } + if appType != "saas" { + updatedAccessApplication.Domain = d.Get("domain").(string) + } + if len(allowedIDPList) > 0 { updatedAccessApplication.AllowedIdps = allowedIDPList } @@ -172,6 +185,11 @@ func resourceCloudflareAccessApplicationUpdate(ctx context.Context, d *schema.Re updatedAccessApplication.CorsHeaders = CORSConfig } + if _, ok := d.GetOk("saas_app"); ok { + saasConfig := convertSaasSchemaToStruct(d) + updatedAccessApplication.SaasApplication = saasConfig + } + tflog.Debug(ctx, fmt.Sprintf("Updating Cloudflare Access Application from struct: %+v", updatedAccessApplication)) identifier, err := initIdentifier(d) diff --git a/internal/provider/resource_cloudflare_access_application_test.go b/internal/provider/resource_cloudflare_access_application_test.go index 54c77ca3ac..fcbbbe9a4b 100644 --- a/internal/provider/resource_cloudflare_access_application_test.go +++ b/internal/provider/resource_cloudflare_access_application_test.go @@ -39,6 +39,7 @@ func TestAccCloudflareAccessApplication_BasicZone(t *testing.T) { resource.TestCheckResourceAttr(name, "type", "self_hosted"), resource.TestCheckResourceAttr(name, "session_duration", "24h"), resource.TestCheckResourceAttr(name, "cors_headers.#", "0"), + resource.TestCheckResourceAttr(name, "saas_app.#", "0"), resource.TestCheckResourceAttr(name, "auto_redirect_to_identity", "false"), ), }, @@ -68,6 +69,7 @@ func TestAccCloudflareAccessApplication_BasicAccount(t *testing.T) { resource.TestCheckResourceAttr(name, "type", "self_hosted"), resource.TestCheckResourceAttr(name, "session_duration", "24h"), resource.TestCheckResourceAttr(name, "cors_headers.#", "0"), + resource.TestCheckResourceAttr(name, "sass_app.#", "0"), resource.TestCheckResourceAttr(name, "auto_redirect_to_identity", "false"), ), }, @@ -106,6 +108,36 @@ func TestAccCloudflareAccessApplication_WithCORS(t *testing.T) { }) } +func TestAccCloudflareAccessApplication_WithSaas(t *testing.T) { + rnd := generateRandomResourceName() + name := fmt.Sprintf("cloudflare_access_application.%s", rnd) + accountID := os.Getenv("CLOUDFLARE_ACCOUNT_ID") + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccessAccPreCheck(t) + }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckCloudflareAccessApplicationDestroy, + Steps: []resource.TestStep{ + { + Config: testAccCloudflareAccessApplicationConfigWithSaas(rnd, accountID), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(name, "account_id", accountID), + resource.TestCheckResourceAttr(name, "name", rnd), + resource.TestCheckResourceAttr(name, "type", "saas"), + resource.TestCheckResourceAttr(name, "session_duration", "24h"), + resource.TestCheckResourceAttr(name, "saas_app.#", "1"), + resource.TestCheckResourceAttr(name, "saas_app.0.sp_entity_id", "saas-app.example"), + resource.TestCheckResourceAttr(name, "saas_app.0.consumer_service_url", "https://saas-app.example/sso/saml/consume"), + resource.TestCheckResourceAttr(name, "saas_app.0.name_id_format", "email"), + ), + }, + }, + }) +} + func TestAccCloudflareAccessApplication_WithAutoRedirectToIdentity(t *testing.T) { rnd := generateRandomResourceName() name := fmt.Sprintf("cloudflare_access_application.%s", rnd) @@ -410,6 +442,23 @@ resource "cloudflare_access_application" "%[1]s" { `, rnd, zoneID, domain) } +func testAccCloudflareAccessApplicationConfigWithSaas(rnd, accountID string) string { + return fmt.Sprintf(` +resource "cloudflare_access_application" "%[1]s" { + account_id = "%[2]s" + name = "%[1]s" + type = "saas" + session_duration = "24h" + saas_app { + consumer_service_url = "https://saas-app.example/sso/saml/consume" + sp_entity_id = "saas-app.example" + name_id_format = "email" + } + auto_redirect_to_identity = false +} +`, rnd, accountID) +} + func testAccCloudflareAccessApplicationConfigWithAutoRedirectToIdentity(rnd, zoneID, domain string) string { return fmt.Sprintf(` resource "cloudflare_access_application" "%[1]s" { diff --git a/internal/provider/schema_cloudflare_access_application.go b/internal/provider/schema_cloudflare_access_application.go index 72535d6a81..fd66863bf8 100644 --- a/internal/provider/schema_cloudflare_access_application.go +++ b/internal/provider/schema_cloudflare_access_application.go @@ -38,15 +38,16 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { }, "domain": { Type: schema.TypeString, - Required: true, + Optional: true, + Computed: true, Description: "The complete URL of the asset you wish to put Cloudflare Access in front of. Can include subdomains or paths. Or both.", }, "type": { Type: schema.TypeString, Optional: true, Default: "self_hosted", - ValidateFunc: validation.StringInSlice([]string{"self_hosted", "ssh", "vnc", "file"}, false), - Description: fmt.Sprintf("The application type. %s", renderAvailableDocumentationValuesStringSlice([]string{"self_hosted", "ssh", "vnc", "file"})), + ValidateFunc: validation.StringInSlice([]string{"self_hosted", "saas", "ssh", "vnc", "file"}, false), + Description: fmt.Sprintf("The application type. %s", renderAvailableDocumentationValuesStringSlice([]string{"self_hosted", "saas", "ssh", "vnc", "file"})), }, "session_duration": { Type: schema.TypeString, @@ -121,6 +122,33 @@ func resourceCloudflareAccessApplicationSchema() map[string]*schema.Schema { }, }, }, + "saas_app": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "SaaS configuration for the Access Application.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "sp_entity_id": { + Type: schema.TypeString, + Required: true, + Description: "A globally unique name for an identity or service provider.", + }, + "consumer_service_url": { + Type: schema.TypeString, + Required: true, + Description: "The service provider's endpoint that is responsible for receiving and parsing a SAML assertion.", + }, + "name_id_format": { + Type: schema.TypeString, + Optional: true, + Default: "email", + ValidateFunc: validation.StringInSlice([]string{"email", "id"}, false), + Description: "The format of the name identifier sent to the SaaS application.", + }, + }, + }, + }, "auto_redirect_to_identity": { Type: schema.TypeBool, Optional: true, @@ -261,3 +289,28 @@ func convertCORSStructToSchema(d *schema.ResourceData, headers *cloudflare.Acces return []interface{}{m} } + +func convertSaasSchemaToStruct(d *schema.ResourceData) *cloudflare.SaasApplication { + SaasConfig := cloudflare.SaasApplication{} + if _, ok := d.GetOk("saas_app"); ok { + SaasConfig.SPEntityID = d.Get("saas_app.0.sp_entity_id").(string) + SaasConfig.ConsumerServiceUrl = d.Get("saas_app.0.consumer_service_url").(string) + SaasConfig.NameIDFormat = d.Get("saas_app.0.name_id_format").(string) + } + + return &SaasConfig +} + +func convertSaasStructToSchema(d *schema.ResourceData, app *cloudflare.SaasApplication) []interface{} { + if _, ok := d.GetOk("saas_app"); !ok { + return []interface{}{} + } + + m := map[string]interface{}{ + "sp_entity_id": app.SPEntityID, + "consumer_service_url": app.ConsumerServiceUrl, + "name_id_format": app.NameIDFormat, + } + + return []interface{}{m} +}