Skip to content

About

Terraform module: terraform-cloudflare-zero-trust-access-application

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

🔐 Cloudflare Zero Trust Access Application Terraform Module

Manage a Zero Trust Access application together with the policies that gate it — targeting cloudflare/cloudflare ~> 5.0.

Terraform Provider Module Version Type Resources

🧩 Overview

This composite manages an Access application and its gate:

  • 🔐 The application (cloudflare_zero_trust_access_application.this) — the protected domain/app.
  • 📜 Its policies (cloudflare_zero_trust_access_policy.this) — one per key via for_each, wired into the app by precedence.
  • 🔒 Deny by default — a policy with no explicit decision defaults to deny; you type allow to grant.

💡 Why it matters: an Access application and the policies protecting it are one access-control unit. Bundling them — and defaulting policy decisions to deny — makes the gate explicit, ordered, and fail-closed.

❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!

🗺️ Where this fits in the family

graph LR
  acct["Cloudflare account (account_id)"]:::ext
  grp["terraform-cloudflare-zero-trust-access-group"]:::sib
  idp["Identity providers (by id)"]:::ext
  za["terraform-cloudflare-zero-trust-access-application (this module)"]:::this
  res["cloudflare_zero_trust_access_application + _policy"]:::keystone

  acct -->|"account_id"| za
  grp -->|"group id in policy rules"| za
  idp -->|"allowed_idps"| za
  za -->|"manages"| res

  classDef this fill:#F38020,color:#fff,stroke:#F38020;
  classDef keystone fill:#FBAD41,color:#000,stroke:#FBAD41;
  classDef sib fill:#f5f5f5,color:#333,stroke:#cccccc;
  classDef ext fill:#eeeeff,color:#333,stroke:#9999ff;
Loading

🧬 What this module builds

graph TD
  aid["account_id"]:::in
  ap["application = name, domain, type, session_duration"]:::in
  po["policies = decision, precedence, include/exclude/require"]:::in
  child["cloudflare_zero_trust_access_policy.this (for_each over policies)"]:::child
  this["cloudflare_zero_trust_access_application.this (keystone)"]:::this
  oid["output: id"]:::out
  oaud["output: aud"]:::out
  opi["output: policy_ids"]:::out

  aid --> this
  ap --> this
  po --> child
  child -->|"policy id + precedence"| this
  this --> oid
  this --> oaud
  child --> opi

  classDef this fill:#F38020,color:#fff,stroke:#F38020;
  classDef child fill:#FBAD41,color:#000,stroke:#FBAD41;
  classDef in fill:#f5f5f5,color:#333,stroke:#cccccc;
  classDef out fill:#eeeeff,color:#333,stroke:#9999ff;
Loading

Resource inventory

Resource Name Cardinality Role
cloudflare_zero_trust_access_application this 1 (keystone) The protected Access application.
cloudflare_zero_trust_access_policy this 0..N (for_each over policies) The gating policies, ordered by precedence.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider cloudflare/cloudflare ~> 5.0
Provider block None — the caller configures the provider and supplies CLOUDFLARE_API_TOKEN out of band.
Scope account_id (per-resource input, not provider config).

Schema notes that bite (verified against the live provider schema):

  • 🔒 decision defaults to deny here (fail-closed) — set allow explicitly to grant, or non_identity/bypass for their specific gates.
  • 🔒 application.type is effectively immutable — changing it forces replacement.
  • ⚠️ Policy order is precedence. The app's policies list carries each policy id with its precedence; keep precedences unique.
  • ℹ️ Rule matchers mirror the Access group module and are typed any here (the union is large and per-rule); reference reusable groups via group = { id = ... }.
  • 🔒 Secrets referenced by id. Service tokens and IdPs are referenced by id — never as plaintext variables.
  • ℹ️ tags here is ..._access_application.tags, a per-app label set — not a library tag tail.

🔑 Required Cloudflare API Token Permissions

  • Access: Apps and Policies · Read
  • Access: Apps and Policies · Write
  • Access: Apps and Policies · Revoke

Cloudflare Prerequisites

  • A Cloudflare Zero Trust organization configured for the account (a team domain).
  • At least one identity provider configured (out of band) for identity-based policies.
  • Account entitlement for Zero Trust Access.

📁 Module Structure

terraform-cloudflare-zero-trust-access-application/
├── providers.tf     # terraform{} + required_providers (cloudflare ~> 5.0); no provider block
├── variables.tf     # account_id, application{}, policies{} (for_each, deny-by-default)
├── main.tf          # cloudflare_zero_trust_access_policy.this (for_each) + _access_application.this
├── outputs.tf       # id first, then account_id, aud, domain, policy_ids
├── README.md        # this document
├── SCOPE.md         # cross-module contract
├── LICENSE          # MIT
└── .gitignore       # canonical library ignore set

⚙️ Quick Start

provider "cloudflare" {}
# export CLOUDFLARE_API_TOKEN=... (scoped to Access: Apps and Policies)

module "internal_app" {
  source     = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id = var.cloudflare_account_id
  application = { name = "Internal Tools", domain = "tools.example.com" }
  policies = {
    staff = { name = "Allow staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] }
  }
}

🔌 Cross-Module Contract

Consumes

Input Type Typical source
account_id string caller
application object({...}) caller
policies map(object({...})) caller; group ids from terraform-cloudflare-zero-trust-access-group

Emits

Output Description Consumed by
id Access application identifier audit / reporting
account_id Account scope (echoed) composition
aud Application audience (AUD) tag JWT / service-token validation
domain Protected domain operational checks
policy_ids map: policy key → id audit

📚 Example Library

1 · Minimal — app + one allow policy
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Wiki", domain = "wiki.example.com" }
  policies    = { staff = { name = "staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] } }
}
2 · Application with no policy (denies everyone)
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Locked", domain = "locked.example.com" }
}

🔒 With no policies, the application admits no one — a safe starting point.

3 · Allow specific emails
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Admin", domain = "admin.example.com" }
  policies = {
    admins = { name = "admins", precedence = 1, decision = "allow", include = [{ email = { email = "sec@example.com" } }] }
  }
}
4 · Gate by a reusable Access group
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Engineering", domain = "eng.example.com" }
  policies = {
    eng = { name = "engineering", precedence = 1, decision = "allow", include = [{ group = { id = var.eng_group_id } }] }
  }
}
5 · Explicit deny (block) policy
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Restricted", domain = "restricted.example.com" }
  policies = {
    block_contractors = { name = "block contractors", precedence = 1, decision = "deny", include = [{ email_domain = { domain = "contractor.example.net" } }] }
    allow_staff       = { name = "allow staff", precedence = 2, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] }
  }
}
6 · Non-identity (service token) access
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "API", domain = "api.example.com", type = "self_hosted" }
  policies = {
    svc = { name = "service token", precedence = 1, decision = "non_identity", include = [{ service_token = { token_id = var.token_id } }] }
  }
}
7 · Bypass for an office IP range
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Intranet", domain = "intranet.example.com" }
  policies = {
    office = { name = "office bypass", precedence = 1, decision = "bypass", include = [{ ip = { ip = "203.0.113.0/24" } }] }
  }
}

⚠️ bypass skips authentication for matching requests — scope it tightly (e.g. a trusted IP range).

8 · Multiple ordered policies
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Layered", domain = "app.example.com" }
  policies = {
    deny_embargo = { name = "deny embargo", precedence = 1, decision = "deny", include = [{ geo = { country_code = "KP" } }] }
    allow_staff  = { name = "allow staff", precedence = 2, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] }
  }
}
9 · Hardened session (binding cookie, short duration)
module "app" {
  source     = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id = var.cloudflare_account_id
  application = {
    name                       = "Sensitive"
    domain                     = "sensitive.example.com"
    session_duration           = "1h"
    enable_binding_cookie      = true
    http_only_cookie_attribute = true
    same_site_cookie_attribute = "strict"
  }
  policies = { staff = { name = "staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] } }
}

🔒 A short session, a binding cookie, HttpOnly, and SameSite=strict tighten the session's blast radius.

10 · Require MFA and device posture
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Prod Console", domain = "console.example.com" }
  policies = {
    staff = {
      name       = "staff mfa"
      precedence = 1
      decision   = "allow"
      include    = [{ email_domain = { domain = "example.com" } }]
      require    = [{ auth_method = { auth_method = "mfa" } }, { device_posture = { integration_uid = var.posture_id } }]
    }
  }
}
11 · SaaS application type
module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Salesforce", type = "saas", allowed_idps = [var.okta_idp_id], auto_redirect_to_identity = true }
  policies    = { all_staff = { name = "staff", precedence = 1, decision = "allow", include = [{ email_domain = { domain = "example.com" } }] } }
}
12 · Allowed IdPs + auto-redirect
module "app" {
  source     = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id = var.cloudflare_account_id
  application = {
    name                      = "SSO App"
    domain                    = "sso.example.com"
    allowed_idps              = [var.okta_idp_id]
    auto_redirect_to_identity = true
  }
  policies = { staff = { name = "staff", precedence = 1, decision = "allow", include = [{ login_method = { id = var.okta_idp_id } }] } }
}
13 · 🏗️ End-to-end composition — group + application + policy
provider "cloudflare" {}

variable "cloudflare_account_id" { type = string }

module "eng_group" {
  source     = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-group.git?ref=v1.0.0"
  account_id = var.cloudflare_account_id
  name       = "engineering"
  include    = [{ email_domain = { domain = "example.com" } }]
  require    = [{ device_posture = { integration_uid = var.posture_id } }]
}

module "app" {
  source      = "git::https://github.com/microsoftexpert/terraform-cloudflare-zero-trust-access-application.git?ref=v1.0.0"
  account_id  = var.cloudflare_account_id
  application = { name = "Engineering Tools", domain = "eng.example.com", session_duration = "8h", enable_binding_cookie = true }
  policies = {
    allow_eng   = { name = "allow engineering", precedence = 2, decision = "allow", include = [{ group = { id = module.eng_group.id } }] }
    deny_embargo = { name = "deny embargo", precedence = 1, decision = "deny", include = [{ geo = { country_code = "KP" } }] }
  }
}

output "app_aud" { value = module.app.aud }

🏗️ The group defines "engineering" once (with device posture); the application gates eng.example.com with an ordered pair of policies — deny embargoed geos first, then allow the group.

📥 Inputs

Name Type Required Default Description
account_id string ✅ — Account the application belongs to.
application object({...}) ✅ — App name, domain, type, session + cookie hardening.
policies map(object({...})) — {} Gating policies (deny-by-default), ordered by precedence.
Full input schemas (from variables.tf)
variable "application" {
  type = object({
    name = optional(string), domain = optional(string), type = optional(string, "self_hosted"), session_duration = optional(string),
    allowed_idps = optional(set(string)), auto_redirect_to_identity = optional(bool),
    enable_binding_cookie = optional(bool), http_only_cookie_attribute = optional(bool), same_site_cookie_attribute = optional(string),
    tags = optional(set(string)), self_hosted_domains = optional(set(string)),
    saas_app = optional(any), cors_headers = optional(any), scim_config = optional(any), mfa_config = optional(any), ... # complex nested passed through
  })
  # validation: type ∈ supported set
}

variable "policies" {
  type = map(object({
    name = string, precedence = number, decision = optional(string, "deny"), # allow|deny|non_identity|bypass
    include = optional(any, []), exclude = optional(any, []), require = optional(any, []),
    session_duration = optional(string), approval_required = optional(bool), isolation_required = optional(bool), ...
  }))
  default = {}
  # validation: decision enum; name non-empty
}

🧾 Outputs

Output Description Notes
id Access application identifier Primary reference.
account_id Account scope (echoed) For composition.
aud Audience (AUD) tag JWT/service-token validation.
domain Protected domain —
policy_ids map: policy key → id Conditional (empty with no policies).

🧠 Architecture Notes

  • Composite, wired by precedence. Policies are for_each children; their ids are collected into the application's policies list with each policy's precedence, so the gate's order lives with the gate.
  • Deny by default. decision defaults to deny — a policy you forget to mark allow fails closed rather than silently granting.
  • Rules mirror Access groups. include/exclude/require use the same matcher union; they're typed any here (the union is large and per-policy) with decision/name/precedence validated, and the provider validates rule specifics at plan.
  • Immutable type. application.type is set at creation; a change replaces the app.

🧱 Design Principles

Concern Secure default How to opt out (deliberately)
Policy decision deny (fail-closed) Set allow/non_identity/bypass explicitly.
policies {} (admits no one) Add policies explicitly.
Secrets Referenced by id only n/a — never plaintext.
Session hardening Opt-in cookie/session controls documented Enable enable_binding_cookie, short session_duration, etc.

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin by immutable tag ?ref=v1.0.0, never a branch.

🧪 Testing

  • ✅ terraform validate — parses application/policies; enforces the app-type and policy-decision enums and policy names.
  • ✅ terraform fmt -check — canonical formatting.
  • ⛔ Not offline: IdP/group/token id validity, Zero Trust org setup, and rule specifics are checked at a real plan/apply.

💬 Example Output

$ terraform output
account_id = "023e105f4ecef8ad9ca31a8372d0c353"
aud        = "d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"
domain     = "tools.example.com"
id         = "0f9c8b7a-6d5e-4c3b-2a1f-0e9d8c7b6a5f"
policy_ids = { "staff" = "699d9864-2c56-4d2e-855e-9661899b7252" }

🔍 Troubleshooting

Symptom Cause Fix
Nobody can access the app No allow policy (deny-by-default) Add a policy with decision = "allow".
each policy decision must be one of ... Bad decision Use allow/deny/non_identity/bypass.
Policies apply in the wrong order Precedence collision Give each policy a unique precedence.
Group not matched Wrong group id in a rule Use the access-group module's id via group = { id = ... }.
application.type change recreates the app Type is immutable Treat a type change as a new application.
Provider auth error No/insufficient token Configure the provider with an Access: Apps and Policies token.

🔗 Related Docs


🧡 "Infrastructure as Code should be standardized, consistent, and secure."

About

Terraform module: terraform-cloudflare-zero-trust-access-application

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages