Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🔑 Cloudflare API Token Terraform Module

Manage a least-privilege Cloudflare API token — scoped by policy, optionally IP- and time-restricted — targeting cloudflare/cloudflare ~> 5.0.

Terraform Provider Module Version Type Resources

🧩 Overview

This module manages one cloudflare_api_token:

  • 🔑 Policy-scoped — grant the fewest permission groups on the narrowest resources.
  • 🌐 IP- and time-bounded — optionally restrict by source CIDR and by a validity window.
  • 🔒 Secret out of band — the token's value is emitted as a sensitive output only; the module never takes a secret as input, and the value cannot be retrieved again after creation.

💡 Why it matters: an over-scoped, non-expiring token is a standing liability. This module makes you enumerate exactly what the token may do, and surfaces the secret only as a sensitive output for immediate capture into a secrets manager.

❤️ 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
  user["Cloudflare user (creating credential)"]:::ext
  tok["terraform-cloudflare-api-token (this module)"]:::this
  res["cloudflare_api_token"]:::keystone
  pg["Permission groups + resources (by id)"]:::ext
  sm["Secrets manager (value, out of band)"]:::ext

  user -->|"API Tokens Write"| tok
  pg -->|"permission group ids"| tok
  tok -->|"manages"| res
  tok -->|"sensitive value"| sm

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

🧬 What this module builds

graph TD
  t["token = name, policies, condition, expires_on"]:::in
  this["cloudflare_api_token.this"]:::this
  oid["output: id"]:::out
  ost["output: status"]:::out
  ov["output: value (sensitive)"]:::out

  t --> this
  this --> oid
  this --> ost
  this --> ov

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

Resource inventory

Resource Name Cardinality Role
cloudflare_api_token this 1 (keystone) A least-privilege API token.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
Provider cloudflare/cloudflare ~> 5.0
Provider block None — the caller configures the provider and supplies auth out of band.
Scope User-level — this resource takes neither zone_id nor account_id.

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

  • 🔒 value is write-once. Cloudflare returns the token secret only at creation; the module marks it sensitive and you must capture it out of band immediately. Losing it means rotating the token.
  • ⚠️ policies[].resources is a JSON object string. Build it with jsonencode({...}) — e.g. jsonencode({ "com.cloudflare.api.account.zone.<zone_id>" = "*" }).
  • ⚠️ policies and permission_groups are lists. Each policy needs an effect (allow/deny), at least one permission group id, and a resources string.
  • ℹ️ status may be set to active (default) or disabled; expired is a computed state, not something you set.
  • ℹ️ Permission group ids come from Cloudflare's permission-groups catalog (a data source), not invented.

🔑 Required Cloudflare API Token Permissions

  • API Tokens · Read
  • API Tokens · Write

Cloudflare Prerequisites

  • The credential creating this token needs User API Tokens Write.
  • The permission group ids you intend to grant (look them up via the permission-groups data source or the dashboard).

📁 Module Structure

terraform-cloudflare-api-token/
├── providers.tf     # terraform{} + required_providers (cloudflare ~> 5.0); no provider block
├── variables.tf     # token{} — name, policies (required), condition, validity window
├── main.tf          # cloudflare_api_token.this (user-scoped)
├── outputs.tf       # id first, then name, status, value (sensitive), issued_on, last_used_on
├── 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 API Tokens read/write)

module "dns_ci_token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "ci-dns-editor"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_permission_group_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
  }
}

output "token_value" {
  value     = module.dns_ci_token.value # capture into a secrets manager, then remove
  sensitive = true
}

🔌 Cross-Module Contract

Consumes

Input Type Typical source
token object({...}) caller (permission group ids + resource scope)

Emits

Output Description Consumed by
id Token identifier (not the secret) audit / rotation
name Token name audit
status Token status posture checks
value Sensitive token secret secrets manager (out of band)
issued_on / last_used_on Timestamps audit

📚 Example Library

1 · Minimal — a single allow policy
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "dns-editor"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
  }
}
2 · Account-scoped token
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "account-reader"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.account_read_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.${var.account_id}" = "*" })
    }]
  }
}
3 · Multiple permission groups in one policy
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "dns-and-cache"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_pg_id }, { id = var.cache_purge_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
  }
}
4 · IP-restricted automation token
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "ci-runner"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
    condition = { request_ip = { in = ["203.0.113.0/24"] } }
  }
}

🔒 An IP allowlist means a leaked token is useless from anywhere but your CI egress range.

5 · Expiring token
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name       = "temp-migration"
    expires_on = "2026-12-31T23:59:59Z"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
  }
}

🔒 Prefer short-lived tokens for one-off work; expiry beats remembering to revoke.

6 · Validity window (not_before + expires_on)
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name       = "scheduled-window"
    not_before = "2026-08-01T00:00:00Z"
    expires_on = "2026-08-31T23:59:59Z"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_read_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
  }
}
7 · IP deny list
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "no-tor"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_read_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
    condition = { request_ip = { not_in = ["198.51.100.0/24"] } }
  }
}
8 · Disabled token (created but inactive)
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name   = "break-glass"
    status = "disabled"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.super_admin_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.${var.account_id}" = "*" })
    }]
  }
}

🔒 A break-glass token can be created disabled and enabled only during an incident.

9 · Explicit deny policy
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "dns-except-one-zone"
    policies = [
      { effect = "allow", permission_groups = [{ id = var.dns_write_pg_id }], resources = jsonencode({ "com.cloudflare.api.account.${var.account_id}" = "*" }) },
      { effect = "deny", permission_groups = [{ id = var.dns_write_pg_id }], resources = jsonencode({ "com.cloudflare.api.account.zone.${var.protected_zone_id}" = "*" }) },
    ]
  }
}
10 · Capturing the value into state-free output
module "token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token  = { name = "app", policies = [{ effect = "allow", permission_groups = [{ id = var.pg_id }], resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" }) }] }
}

output "token_value" {
  value     = module.token.value
  sensitive = true
}

🔒 Read the value once (e.g. terraform output -raw token_value), store it in your secrets manager, and rely on rotation thereafter.

11 · Many tokens from one definition (for_each)
locals {
  tokens = {
    ci   = { pg = var.dns_write_pg_id }
    read = { pg = var.dns_read_pg_id }
  }
}

module "tokens" {
  source   = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  for_each = local.tokens
  token = {
    name     = "svc-${each.key}"
    policies = [{ effect = "allow", permission_groups = [{ id = each.value.pg }], resources = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" }) }]
  }
}
12 · Least-privilege DNS automation token (realistic)
module "dns_automation" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name       = "terraform-dns-${var.environment}"
    expires_on = timeadd(timestamp(), "8760h") # ~1 year; rotate annually
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${var.zone_id}" = "*" })
    }]
    condition = { request_ip = { in = var.ci_egress_cidrs } }
  }
}

🔒 Narrow permission group, single zone, IP-bounded, and time-limited — the least-privilege ideal.

13 · 🏗️ End-to-end composition — a token scoped to a zone you manage
provider "cloudflare" {}

variable "cloudflare_account_id" { type = string }
variable "dns_write_pg_id" { type = string }

module "zone" {
  source     = "git::https://github.com/microsoftexpert/terraform-cloudflare-zone.git?ref=v1.0.0"
  account_id = var.cloudflare_account_id
  zone       = { name = "example.com" }
}

module "zone_dns_token" {
  source = "git::https://github.com/microsoftexpert/terraform-cloudflare-api-token.git?ref=v1.0.0"
  token = {
    name = "dns-editor-example-com"
    policies = [{
      effect            = "allow"
      permission_groups = [{ id = var.dns_write_pg_id }]
      resources         = jsonencode({ "com.cloudflare.api.account.zone.${module.zone.id}" = "*" }) # <-- scoped to the zone's id
    }]
  }
}

output "dns_token" {
  value     = module.zone_dns_token.value
  sensitive = true
}

🏗️ The token is scoped precisely to the zone this configuration created — no broader than it needs to be.

📥 Inputs

Name Type Required Default Description
token object({...}) Token name, policies (required, non-empty), and optional restrictions.
Full input schema (from variables.tf)
variable "token" {
  type = object({
    name   = string
    status = optional(string) # "active" | "disabled"
    policies = list(object({
      effect            = string # "allow" | "deny"
      permission_groups = list(object({ id = string }))
      resources         = string # JSON object string — use jsonencode({...})
    }))
    condition  = optional(object({ request_ip = optional(object({ in = optional(list(string)), not_in = optional(list(string)) })) }))
    expires_on = optional(string) # RFC3339
    not_before = optional(string) # RFC3339
  })
  # validation: name non-empty; policies non-empty; each effect ∈ {allow,deny}; each policy has ≥1 permission group; status ∈ {active,disabled}
}

🧾 Outputs

Output Description Notes
id Token identifier Not the secret.
name Token name
status Token status
value Token secret Sensitive; write-once.
issued_on / last_used_on Timestamps Provider-computed.

🧠 Architecture Notes

  • User-scoped. Unlike most Cloudflare resources, an API token takes neither zone_id nor account_id; scope lives inside each policy's resources string.
  • Secret discipline. The token secret is emitted only as a sensitive output and is unrecoverable after creation. Capture it once into a secrets manager. The module never accepts a secret as input.
  • resources is JSON-as-string. Use jsonencode for a type-checked map on the caller side that renders to the string the provider expects.
  • Least privilege by construction. Validations force at least one policy and at least one permission group per policy, so a do-nothing (or accidentally empty) token cannot be created.

🧱 Design Principles

Concern Secure default How to opt out (deliberately)
Secret handling value is sensitive, never an input n/a — secrets stay out of band.
token.status active (usable) Set disabled for break-glass tokens.
Scope You must enumerate policies/resources Broaden resources deliberately (not recommended).
Expiry / IP Off unless set Set expires_on / condition.request_ip to bound the token.

🚀 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 the token object; enforces non-empty policies and permission groups and the effect/status enums.
  • terraform fmt -check — canonical formatting.
  • ⛔ Not offline: permission group id validity and the resource-string format are only checked at a real plan/apply.

💬 Example Output

$ terraform output
id           = "ed17574386854bf78a67040be0a770b0"
issued_on    = "2026-07-18T15:04:05Z"
last_used_on = tostring(null)
name         = "ci-dns-editor"
status       = "active"
value        = <sensitive>

🔍 Troubleshooting

Symptom Cause Fix
token.policies must contain at least one policy Empty policies Add at least one policy.
each policy must grant at least one permission group Empty permission_groups Add a permission group id.
resources rejected Passed a map instead of a string Wrap with jsonencode({...}).
Can't read the token value again Secret is write-once Rotate the token; capture the new value immediately.
Unknown permission group Made-up id Look up ids via the permission-groups data source.
Provider auth error No/insufficient token Configure the provider with an API Tokens read/write token.

🔗 Related Docs

  • Cloudflare provider — cloudflare_api_token
  • Sibling module: terraform-cloudflare-account-member (account IAM)
  • This module's SCOPE.md — the cross-module contract.

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

About

Terraform module: terraform-cloudflare-api-token

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages