Skip to content

feat(cloudflare-tunnel): manage the Access app and allow policy per route - #34

Merged
opariffazman merged 3 commits into
mainfrom
fm/4d-furqbg
Oct 1, 2026
Merged

opariffazman merged 3 commits into
mainfrom
fm/4d-furqbg

Conversation

@opariffazman

@opariffazman opariffazman commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Closes #32

4D item furqbg: #32 (board: https://github.com/orgs/officialdad/projects/3)

Summary

A consumer marks a cloudflare-tunnel route as Access-protected with an access object. One apply creates the Access application, its allow policy and the cloudflared JWT check, and a renamed hostname carries all three with it. Routes without access plan as before.

Component(s)

cloudflare-tunnel (plus the root README row)

Type

  • Added - new component or feature (MINOR)
  • Changed - behavior change to an existing component
  • Fixed - bug fix (PATCH)
  • Removed
  • Breaking input/output change - callers must edit their config (MAJOR; flag loudly in CHANGELOG)

Changes

  • routes[].access (optional, default null): emails, email_domains, policy_ids, allowed_idps. New access_team_name input, default null.
  • One self_hosted cloudflare_zero_trust_access_application per distinct protected hostname, and one allow cloudflare_zero_trust_access_policy when the route lists emails or email domains.
  • origin_request.access on each protected ingress rule: required = true, team_name, aud_tag = [app aud].
  • cloudflare_dns_record has depends_on on the Access applications.
  • Outputs access_application_ids and access_aud_tags, keyed by hostname.
  • Component README (Access section, token scope, adoption trap), root README row, and tests/access.tftest.hcl on mock_provider "cloudflare".
  • Separate style(repo) commit: trailing whitespace stripped in vendored .agents/skills files, because pre-commit run --all-files failed on them.

Design choice

Approach Trade-off
access object on each route (chosen) Access is derived from the hostname, so a rename moves the app and policy with the route and cannot leave a hostname open. This is the rename-proof option officialdad/infra-environments-dev#40 asks for. Cost: routes on one hostname must repeat the same object, which a validation enforces.
Separate access_applications map keyed by hostname One place lists the apps. Cost: the key is a second copy of the hostname, so a rename that misses the map leaves the route open. That is the failure in #40.

Done-when entry met by reading the code, not by a test

terraform test cannot assert resource dependencies. The Done-when entry "cloudflare_dns_record for an access route depends on the application" is met by the explicit depends_on at cloudflare-tunnel/terraform/main.tf (in cloudflare_dns_record.this), which a reviewer can read. terraform graph also shows the edge. The owner accepted this on the review gate.

Known gaps

  • Review finding not applied: access_team_name = "" passes the null-only precondition and writes an empty team_name. A variable validation would close it. It is left for a follow-up.
  • A green plan proves nothing yet: the CI token lacks the Access scope, and every new resource is a create. Plans ran only against the mock provider.

Owner checklist (after merge)

  • Release: run scripts/release.sh X.Y.Z and push main plus the vX.Y.Z tag. The number is whatever is next when this merges.
  • Token scope: add Access: Apps and Policies Edit to the Cloudflare API token that dev and prod CI use, then check it with the tokens/verify curl.
  • Identity provider: set up Google in Zero Trust and check its id in the dashboard. The consumer passes it as allowed_idps.
  • Allow list: decide where the staff email list lives. Recommendation: the private infra-environments-prod repository, as input to the consumer Terragrunt.
  • Split the hand-made Access app that covers both the dev and prod hostnames into one app per environment, then import each into its environment state. Skipping the split makes the first apply strip the other environment's hostname and reopens infra-environments-dev#40.
  • Dev: bump the cloudflare_tunnel pin, apply, then soak. Check that the dev hostname returns a 302 to the Zero Trust login and that a request with no JWT is refused.
  • Prod: bump the pin and apply with the apply:cloudflare_tunnel label.
  • Rollback after release: hand-make the replacement Access app first, then pin back to the previous tag in versions.hcl, so the hostname is never unprotected.

Validation

  • terraform fmt / validate / tflint pass on the changed components (the gate)
  • Commit subjects are consumer-facing, type(scope): subject with scope = component
  • Component README / root README updated if inputs or outputs changed
  • terraform -chdir=cloudflare-tunnel/terraform test: 8 passed, 0 failed
  • pre-commit run --all-files exits 0

What Changed

  • cloudflare-tunnel: adds an optional access object to each route (emails, email_domains, policy_ids, allowed_idps) and a new access_team_name input. For each protected hostname, the module creates a cloudflare_zero_trust_access_application. It also creates an allow cloudflare_zero_trust_access_policy when the route lists emails or email domains. The module sets the tunnel ingress origin_request.access so cloudflared checks the Access JWT, and DNS records wait until the Access apps exist. The policies list is built from known inputs only, so its length is known at plan time.
  • Validation: every route on one hostname must carry the same access object. An access block must name at least one allow entry. Wildcards, empty values, and an empty allowed_idps list are rejected. A precondition requires access_team_name when any route is protected. Unprotected routes plan the same as before.
  • Adds access_application_ids and access_aud_tags outputs, a tests/access.tftest.hcl suite, and updates to the component and root READMEs. Trailing whitespace is removed from the vendored .agents/skills docs.

Risk Assessment

⚠️ Medium: Access is optional and defaults to null, and every attribute used matches the cloudflare 5.26.0 schema, but one required Done-when test is missing and a wrong team name makes a protected hostname unreachable.

Testing

I built a disposable harness in /tmp with copies of the module from the branch and from main, then removed it. First I ran the mock-provider terraform test file, and all 8 runs pass. Then I ran real terraform plan with the real Cloudflare provider 5.26.0, a fake token, and an API URL that goes nowhere. Four protected-route shapes now plan with exit 0. That includes the emails-only and email_domains-plus-policy_ids routes that failed in round 1. All six guard cases fail with the intended error message. The plan JSON shows these values: allowed_idps passes through as given, auto_redirect_to_identity is true only with one identity provider, and an emails-only policy holds one email rule and no email_domain rule. Each protected ingress rule has required=true, team_name, and an aud_tag taken from the app. The DNS record depends on the Access application. The no-access plan is identical on main and the branch, and both access outputs are empty. A two-step mock apply shows a hostname rename moves the app and the policy to the new hostname. That rename check is not live, because a real apply needs Cloudflare credentials. The placeholder grep prints only example.com. The worktree is clean.

  • Live validation: ✅ go - 11 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Consumer protects a route with a staff email and one identity provider id and runs terraform plan. The plan creates the app and the allow policy, and each protected ingress rule gets required=true ✅ pass live r2-real-provider-plans.txt (emails_one_idp exit=0, 5 to add); r2-access-plan-details.txt (allowed_idps=[example-idp-id], auto_redirect_to_identity=true, one email rule and no email_domain rule, requir…
Consumer protects a route with email_domains plus an existing policy id and plans. The app lists the new policy first and the existing policy second ✅ pass live r2-real-provider-plans.txt (domains_plus_policy exit=0, 4 to add); r2-access-plan-details.txt (policies precedence 1 = module policy, precedence 2 = example-policy-id)
Consumer protects a route with policy_ids only. The plan creates the app with no new policy ✅ pass live r2-real-provider-plans.txt (policy_ids_only exit=0, 3 to add, no access_policy)
Consumer passes two identity provider ids. The app lists both, and auto redirect stays unset ✅ pass live r2-access-plan-details.txt (two_idps_no_redirect: allowed_idps has 2 ids, auto_redirect_to_identity=null)
Consumer only bumps the tag, with no access block. The plan matches main exactly, with no Access resources and empty access outputs ✅ pass live r2-backcompat-no-access.txt (diff of planned resource_changes main vs branch prints IDENTICAL; origin_request null; access_application_ids and access_aud_tags = {})
DNS record for a protected hostname depends on the Access application, so no host resolves early ✅ pass live r2-access-plan-details.txt dns_depends_on = [cloudflare_zero_trust_access_application.this]
Adversarial: allowed_idps = [] or a blank id is rejected at plan ✅ pass live r2-real-provider-plans.txt reject_empty_idps and reject_blank_idp exit=1 with the allowed_idps validation message
Adversarial: an empty access block, a wildcard everyone rule, or a half-protected hostname is rejected at plan ✅ pass live r2-real-provider-plans.txt reject_empty_access, reject_everyone_wildcard, reject_half_protected exit=1 with the named validation messages
Adversarial: an access route with no access_team_name fails the plan before the tunnel config is written ✅ pass live r2-real-provider-plans.txt reject_no_team_name exit=1, Resource precondition failed
Mock-provider terraform test file covers the no-access case, the one-access case, and the expect_failures cases ✅ pass live r2-terraform-test.txt (8 passed, exit=0)
Renaming a protected hostname moves the Access app and policy to the new hostname ⏸️ untested no The prior payload did not establish a live result. The rename check ran only as two mock applies through terraform test with mock_provider (r2-rename-mock-apply.txt). A real-provider apply needs real…
Repo holds placeholders only (git grep for @ and 32-hex prints only example.com) ✅ pass live r2-placeholder-grep.txt (lines not mentioning example.com: NONE)
Evidence: Real-provider plans for every scenario (8e928a2)
# Real terraform plan on 8e928a2, cloudflare provider 5.26.0, fake token, API base URL 127.0.0.1:9 (no network)
=== emails_one_idp (exit=0)
    # cloudflare_dns_record.this["admin.example.com"] will be created
    # cloudflare_dns_record.this["qr.example.com"] will be created
    # cloudflare_zero_trust_access_application.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_policy.this["admin.example.com"] will be created
    # cloudflare_zero_trust_tunnel_cloudflared_config.this will be created
  Plan: 5 to add, 0 to change, 0 to destroy.
=== domains_plus_policy (exit=0)
    # cloudflare_dns_record.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_application.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_policy.this["admin.example.com"] will be created
    # cloudflare_zero_trust_tunnel_cloudflared_config.this will be created
  Plan: 4 to add, 0 to change, 0 to destroy.
=== policy_ids_only (exit=0)
    # cloudflare_dns_record.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_application.this["admin.example.com"] will be created
    # cloudflare_zero_trust_tunnel_cloudflared_config.this will be created
  Plan: 3 to add, 0 to change, 0 to destroy.
=== two_idps_no_redirect (exit=0)
    # cloudflare_dns_record.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_application.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_policy.this["admin.example.com"] will be created
    # cloudflare_zero_trust_tunnel_cloudflared_config.this will be created
  Plan: 4 to add, 0 to change, 0 to destroy.
=== reject_empty_idps (exit=1)
  Error: Invalid value for variable
  access allowed_idps must be null (keep the account's login methods) or a
=== reject_blank_idp (exit=1)
  Error: Invalid value for variable
  access allowed_idps must be null (keep the account's login methods) or a
=== reject_empty_access (exit=1)
  Error: Invalid value for variable
  An access block must name at least one of emails, email_domains or
=== reject_everyone_wildcard (exit=1)
  Error: Invalid value for variable
  access emails, email_domains and policy_ids must be non-empty and free of
=== reject_half_protected (exit=1)
  Error: Invalid value for variable
  Every route on the same hostname must carry the same access object (or none).
=== reject_no_team_name (exit=1)
    # cloudflare_dns_record.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_application.this["admin.example.com"] will be created
    # cloudflare_zero_trust_access_policy.this["admin.example.com"] will be created
  Plan: 3 to add, 0 to change, 0 to destroy.
  Error: Resource precondition failed
      │ var.access_team_name is null
  access_team_name is required when any route has an access block. cloudflared
Evidence: Planned Access app, policy, ingress and DNS depends_on from plan JSON
=== emails_one_idp: planned values from terraform show -json
{
  "application": [
    {
      "address": "cloudflare_zero_trust_access_application.this[\"admin.example.com\"]",
      "type_": "self_hosted",
      "domain": "admin.example.com",
      "allowed_idps": [
        "example-idp-id"
      ],
      "auto_redirect_to_identity": true,
      "policies": [
        {
          "connection_rules": null,
          "decision": null,
          "exclude": null,
          "include": null,
          "mfa_config": null,
          "name": null,
          "precedence": 1,
          "require": null
        }
      ],
      "policies_unknown": [
        {
          "id": true
        }
      ]
    }
  ],
  "policy": [
    {
      "address": "cloudflare_zero_trust_access_policy.this[\"admin.example.com\"]",
      "name": "admin.example.com-allow",
      "decision": "allow",
      "include": [
        {
          "any_valid_service_token": null,
          "auth_context": null,
          "auth_method": null,
          "azure_ad": null,
          "certificate": null,
          "cloudflare_account_member": null,
          "common_name": null,
          "device_posture": null,
          "email": {
            "email": "staff@example.com"
          },
          "email_domain": null,
          "email_list": null,
          "everyone": null,
          "external_evaluation": null,
          "geo": null,
          "github_organization": null,
          "group": null,
          "gsuite": null,
          "ip": null,
          "ip_list": null,
          "linked_app_token": null,
          "login_method": null,
          "oidc": null,
          "okta": null,
          "saml": null,
          "service_token": null,
          "user_risk_score": null
        }
      ]
    }
  ],
  "ingress": [
    {
      "hostname": "admin.example.com",
      "path": "^/api",
      "service": "http://localhost:3001",
      "access": {
        "aud_tag": [
          null
        ],
        "required": true,
        "team_name": "example-team"
      }
    },
    {
      "hostname": "admin.example.com",
      "path": null,
      "service": "http://localhost:3001",
      "access": {
        "aud_tag": [
          null
        ],
        "required": true,
        "team_name": "example-team"
      }
    },
    {
      "hostname": "qr.example.com",
      "path": null,
      "service": "http://localhost:3002",
      "access": null
    },
    {
      "hostname": null,
      "path": null,
      "service": "http_status:404",
      "access": null
    }
  ],
  "aud_tag_unknown": [
    [
      true
    ],
    [
      true
    ],
    null,
    null
  ],
  "dns_depends_on": [
    [
      "cloudflare_zero_trust_access_application.this"
    ]
  ]
}
=== domains_plus_policy: planned values from terraform show -json
{
  "application": [
    {
      "address": "cloudflare_zero_trust_access_application.this[\"admin.example.com\"]",
      "type_": "self_hosted",
      "domain": "admin.example.com",
      "allowed_idps": null,
      "auto_redirect_to_identity": null,
      "policies": [
        {
          "connection_rules": null,
          "decision": null,
          "exclude": null,
          "include": null,
          "mfa_config": null,
          "name": null,
          "precedence": 1,
          "require": null
        },
        {
          "connection_rules": null,
          "decision": null,
          "exclude": null,
          "id": "example-policy-id",
          "include": null,
          "mfa_config": null,
          "name": null,
          "precedence": 2,
          "require": null
        }
      ],
      "policies_unknown": [
        {
          "id": true
        },
        {}
      ]
    }
  ],
  "policy": [
    {
      "address": "cloudflare_zero_trust_access_policy.this[\"admin.example.com\"]",
      "name": "admin.example.com-allow",
      "decision": "allow",
      "include": [
        {
          "any_valid_service_token": null,
          "auth_context": null,
          "auth_method": null,
          "azure_ad": null,
          "certificate": null,
          "cloudflare_account_member": null,
          "common_name": null,
          "device_posture": null,
          "email": null,
          "email_domain": {
            "domain": "example.com"
          },
          "email_list": null,
          "everyone": null,
          "external_evaluation": null,
          "geo": null,
          "github_organization": null,
          "group": null,
          "gsuite": null,
          "ip": null,
          "ip_list": null,
          "linked_app_token": null,
          "login_method": null,
          "oidc": null,
          "okta": null,
          "saml": null,
          "service_token": null,
          "user_risk_score": null
        }
      ]
    }
  ],
  "ingress": [
    {
      "hostname": "admin.example.com",
      "path": null,
      "service": "http://localhost:3001",
      "access": {
        "aud_tag": [
          null
        ],
        "required": true,
        "team_name": "example-team"
      }
    },
    {
      "hostname": null,
      "path": null,
      "service": "http_status:404",
      "access": null
    }
  ],
  "aud_tag_unknown": [
    [
      true
    ],
    null
  ],
  "dns_depends_on": [
    [
      "cloudflare_zero_trust_access_application.this"
    ]
  ]
}
=== policy_ids_only: planned values from terraform show -json
{
  "application": [
    {
      "address": "cloudflare_zero_trust_access_application.this[\"admin.example.com\"]",
      "type_": "self_hosted",
      "domain": "admin.example.com",
      "allowed_idps": null,
      "auto_redirect_to_identity": null,
      "policies": [
        {
          "connection_rules": null,
          "decision": null,
          "exclude": null,
          "id": "example-policy-id",
          "include": null,
          "mfa_config": null,
          "name": null,
          "precedence": 1,
          "require": null
        }
      ],
      "policies_unknown": [
        {}
      ]
    }
  ],
  "policy": [],
  "ingress": [
    {
      "hostname": "admin.example.com",
      "path": null,
      "service": "http://localhost:3001",
      "access": {
        "aud_tag": [
          null
        ],
        "required": true,
        "team_name": "example-team"
      }
    },
    {
      "hostname": null,
      "path": null,
      "service": "http_status:404",
      "access": null
    }
  ],
  "aud_tag_unknown": [
    [
      true
    ],
    null
  ],
  "dns_depends_on": [
    [
      "cloudflare_zero_trust_access_application.this"
    ]
  ]
}
=== two_idps_no_redirect: planned values from terraform show -json
{
  "application": [
    {
      "address": "cloudflare_zero_trust_access_application.this[\"admin.example.com\"]",
      "type_": "self_hosted",
      "domain": "admin.example.com",
      "allowed_idps": [
        "example-idp-id",
        "example-idp-id-2"
      ],
      "auto_redirect_to_identity": null,
      "policies": [
        {
          "connection_rules": null,
          "decision": null,
          "exclude": null,
          "include": null,
          "mfa_config": null,
          "name": null,
          "precedence": 1,
          "require": null
        }
      ],
      "policies_unknown": [
        {
          "id": true
        }
      ]
    }
  ],
  "policy": [
    {
      "address": "cloudflare_zero_trust_access_policy.this[\"admin.example.com\"]",
      "name": "admin.example.com-allow",
      "decision": "allow",
      "include": [
        {
          "any_valid_service_token": null,
          "auth_context": null,
          "auth_method": null,
          "azure_ad": null,
          "certificate": null,
          "cloudflare_account_member": null,
          "common_name": null,
          "device_posture": null,
          "email": {
            "email": "staff@example.com"
          },
          "email_domain": null,
          "email_list": null,
          "everyone": null,
          "external_evaluation": null,
          "geo": null,
          "github_organization": null,
          "group": null,
          "gsuite": null,
          "ip": null,
          "ip_list": null,
          "linked_app_token": null,
          "login_method": null,
          "oidc": null,
          "okta": null,
          "saml": null,
          "service_token": null,
          "user_risk_score": null
        }
      ]
    }
  ],
  "ingress": [
    {
      "hostname": "admin.example.com",
      "path": null,
      "service": "http://localhost:3001",
      "access": {
        "aud_tag": [
          null
        ],
        "required": true,
        "team_name": "example-team"
      }
    },
    {
      "hostname": null,
      "path": null,
      "service": "http_status:404",
      "access": null
    }
  ],
  "aud_tag_unknown": [
    [
      true
    ],
    null
  ],
  "dns_depends_on": [
    [
      "cloudflare_zero_trust_access_application.this"
    ]
  ]
}
Evidence: No-access plan main vs branch (IDENTICAL)
# Same no-access routes planned on main (31b9149) and branch (8e928a2), real cloudflare provider 5.26.0, no network
## branch resource addresses + actions
["cloudflare_dns_record.this[\"app.example.com\"]",["create"]]
["cloudflare_dns_record.this[\"qr.example.com\"]",["create"]]
["cloudflare_zero_trust_tunnel_cloudflared_config.this",["create"]]
## diff of planned resource_changes main vs branch (empty = identical)
IDENTICAL
## branch planned ingress
[
  {
    "hostname": "app.example.com",
    "origin_request": null,
    "path": "^/api",
    "service": "http://localhost:3000"
  },
  {
    "hostname": "app.example.com",
    "origin_request": null,
    "path": null,
    "service": "http://localhost:3000"
  },
  {
    "hostname": "qr.example.com",
    "origin_request": null,
    "path": null,
    "service": "http://localhost:3002"
  },
  {
    "hostname": null,
    "origin_request": null,
    "path": null,
    "service": "http_status:404"
  }
]
## branch outputs
{
  "access_application_ids": {
    "sensitive": false,
    "type": [
      "object",
      {}
    ],
    "value": {}
  },
  "access_aud_tags": {
    "sensitive": false,
    "type": [
      "object",
      {}
    ],
    "value": {}
  }
}
Evidence: terraform test with mock_provider
tests/access.tftest.hcl... in progress
  run "no_access_block_creates_no_access_resources"... pass
  run "access_route_creates_one_application_and_one_policy"... pass
  run "policy_ids_only_attaches_existing_policies"... pass
  run "empty_allowed_idps_is_rejected"... pass
  run "access_without_any_rule_is_rejected"... pass
  run "wildcard_email_is_rejected"... pass
  run "half_protected_hostname_is_rejected"... pass
  run "access_without_team_name_is_rejected"... pass
tests/access.tftest.hcl... tearing down
tests/access.tftest.hcl... pass

Success! 8 passed, 0 failed.
exit=0
Evidence: Rename moves Access (two mock applies)
tests/rename.tftest.hcl... in progress
  run "apply_old_hostname"... pass
# cloudflare_dns_record.this["qr-old.example.com"]:
resource "cloudflare_dns_record" "this" {
    comment_modified_on     = "of2jionz"
    content                 = "example-tunnel-id.cfargotunnel.com"
    created_on              = "5cpw4lh1"
    id                      = "gqbyre0y"
    include_shadow_metadata = false
    meta                    = "yib47d24"
    modified_on             = "4sbtxscx"
    name                    = "qr-old.example.com"
    proxiable               = false
    proxied                 = true
    settings                = {
        flatten_cname = false
        ipv4_only     = false
        ipv6_only     = false
    }
    tags                    = []
    tags_modified_on        = "vwma73tk"
    ttl                     = 1
    type                    = "CNAME"
    zone_id                 = "example-zone-id"
}
# cloudflare_zero_trust_access_application.this["qr-old.example.com"]:
resource "cloudflare_zero_trust_access_application" "this" {
    account_id                   = "example-account-id"
    app_launcher_visible         = false
    aud                          = "zeg6oq1l"
    destinations                 = []
    domain                       = "qr-old.example.com"
    http_only_cookie_attribute   = false
    id                           = "bn5j4r4m"
    name                         = "qr-old.example.com"
    policies                     = [
        {
            id         = "qq874ldd"
            precedence = 1
        },
    ]
    self_hosted_domains          = []
    session_duration             = "p2enn4zz"
    skip_app_launcher_login_page = false
    type                         = "self_hosted"
}
# cloudflare_zero_trust_access_policy.this["qr-old.example.com"]:
resource "cloudflare_zero_trust_access_policy" "this" {
    account_id = "example-account-id"
    app_count  = 0
    created_at = "5xusu68p"
    decision   = "allow"
    exclude    = []
    id         = "qq874ldd"
    include    = [
        {
            email = {
                email = "staff@example.com"
            }
        },
    ]
    name       = "qr-old.example.com-allow"
    require    = []
    reusable   = false
    updated_at = "ajm4abfu"
}
# cloudflare_zero_trust_tunnel_cloudflared_config.this:
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
    account_id = "example-account-id"
    config     = {
        ingress = [
            {
                hostname       = "qr-old.example.com"
                origin_request = {
                    access = {
                        aud_tag   = [
                            "zeg6oq1l",
                        ]
                        required  = true
                        team_name = "example-team"
                    }
                }
                service        = "http://localhost:3002"
            },
            {
                service = "http_status:404"
            },
        ]
    }
    created_at = "aazw2att"
    id         = "gp6ki6x6"
    source     = "9f1jjip3"
    tunnel_id  = "example-tunnel-id"
    version    = 0
}
Outputs:
access_application_ids = {
    "qr-old.example.com" = "bn5j4r4m"
}
access_aud_tags = {
    "qr-old.example.com" = "zeg6oq1l"
}
dns_record_ids = {
    "qr-old.example.com" = "gqbyre0y"
}
hostnames = [
    "qr-old.example.com",
]
tunnel_id = "example-tunnel-id"
  run "rename_moves_access"... pass
# cloudflare_dns_record.this["qr-new.example.com"]:
resource "cloudflare_dns_record" "this" {
    comment_modified_on     = "nk0ra1yk"
    content                 = "example-tunnel-id.cfargotunnel.com"
    created_on              = "81pd73g9"
    id                      = "ergghjmb"
    include_shadow_metadata = false
    meta                    = "5b60eb17"
    modified_on             = "yyog4uoa"
    name                    = "qr-new.example.com"
    proxiable               = false
    proxied                 = true
    settings                = {
        flatten_cname = false
        ipv4_only     = false
        ipv6_only     = false
    }
    tags                    = []
    tags_modified_on        = "owqjzdoe"
    ttl                     = 1
    type                    = "CNAME"
    zone_id                 = "example-zone-id"
}
# cloudflare_zero_trust_access_application.this["qr-new.example.com"]:
resource "cloudflare_zero_trust_access_application" "this" {
    account_id                   = "example-account-id"
    app_launcher_visible         = false
    aud                          = "wtdt9xg2"
    destinations                 = []
    domain                       = "qr-new.example.com"
    http_only_cookie_attribute   = false
    id                           = "b0htl2u9"
    name                         = "qr-new.example.com"
    policies                     = [
        {
            id         = "wh4jk7cz"
            precedence = 1
        },
    ]
    self_hosted_domains          = []
    session_duration             = "2jdl37q6"
    skip_app_launcher_login_page = false
    type                         = "self_hosted"
}
# cloudflare_zero_trust_access_policy.this["qr-new.example.com"]:
resource "cloudflare_zero_trust_access_policy" "this" {
    account_id = "example-account-id"
    app_count  = 0
    created_at = "28s19rvi"
    decision   = "allow"
    exclude    = []
    id         = "wh4jk7cz"
    include    = [
        {
            email = {
                email = "staff@example.com"
            }
        },
    ]
    name       = "qr-new.example.com-allow"
    require    = []
    reusable   = false
    updated_at = "wc8zys4d"
}
# cloudflare_zero_trust_tunnel_cloudflared_config.this:
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
    account_id = "example-account-id"
    config     = {
        ingress = [
            {
                hostname       = "qr-new.example.com"
                origin_request = {
                    access = {
                        aud_tag   = [
                            "wtdt9xg2",
                        ]
                        required  = true
                        team_name = "example-team"
                    }
                }
                service        = "http://localhost:3002"
            },
            {
                service = "http_status:404"
            },
        ]
    }
    created_at = "aazw2att"
    id         = "gp6ki6x6"
    source     = "9f1jjip3"
    tunnel_id  = "example-tunnel-id"
    version    = 0
}
Outputs:
access_application_ids = {
    "qr-new.example.com" = "b0htl2u9"
}
access_aud_tags = {
    "qr-new.example.com" = "wtdt9xg2"
}
dns_record_ids = {
    "qr-new.example.com" = "ergghjmb"
}
hostnames = [
    "qr-new.example.com",
]
tunnel_id = "example-tunnel-id"
tests/rename.tftest.hcl... tearing down
tests/rename.tftest.hcl... pass
Success! 2 passed, 0 failed.
exit=0
Evidence: Placeholder grep
$ git grep -nE "@|[0-9a-f]{32}" -- cloudflare-tunnel/   (at 8e928a2)
cloudflare-tunnel/README.md:90:      emails       = ["staff@example.com"]
cloudflare-tunnel/terraform/tests/access.tftest.hcl:59:          emails       = ["staff@example.com"]
cloudflare-tunnel/terraform/tests/access.tftest.hcl:67:          emails       = ["staff@example.com"]
cloudflare-tunnel/terraform/tests/access.tftest.hcl:173:      { hostname = "admin.example.com", service = "http://localhost:3001", access = { emails = ["staff@example.com"], allowed_idps = [] } },
cloudflare-tunnel/terraform/tests/access.tftest.hcl:213:      { hostname = "admin.example.com", service = "http://localhost:3001", access = { emails = ["staff@example.com"] } },
cloudflare-tunnel/terraform/tests/access.tftest.hcl:225:      { hostname = "admin.example.com", service = "http://localhost:3001", access = { emails = ["staff@example.com"] } },
## lines not mentioning example.com:
NONE
- Outcome: 🔧 2 issues found → auto-fixed ✅ across 2 runs (8m32s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 issues (1 warning, 2 infos)
  • ⚠️ cloudflare-tunnel/terraform/tests/access.tftest.hcl:48 - A required Done-when criterion has no test. The criterion reads: "The same terraform test run: cloudflare_dns_record for an access route depends on the application." No run block in access.tftest.hcl asserts anything about cloudflare_dns_record. The only evidence is depends_on = [cloudflare_zero_trust_access_application.this] at main.tf:116. terraform test has no assertion that can read resource dependencies, so this criterion cannot be met as literally written. The owner must decide how to close it: accept main.tf:116 plus a note in the PR body, accept a proxy assertion, or amend the criterion. Needs an owner decision because the change cannot fix this on its own.
  • ℹ️ cloudflare-tunnel/terraform/main.tf:96 - The precondition only rejects a null access_team_name. An empty string passes it. Example: access_team_name = "" with one protected route plans cleanly and writes team_name = "" into origin_request.access. The README says cloudflared then rejects every request, so the hostname goes dark. Fix: add a validation on variable "access_team_name" (variables.tf:76) that requires null or trimspace(var.access_team_name) != "".
  • ℹ️ cloudflare-tunnel/terraform/main.tf:80 - This line reads the module's own policy id with [for p in [try(cloudflare_zero_trust_access_policy.this[each.key], null)] : p.id if p != null]. The pattern is hard to read, and try() hides any error in that reference. local.access_policy_by_hostname already says whether this hostname has a policy. Clearer form: contains(keys(local.access_policy_by_hostname), each.key) ? [cloudflare_zero_trust_access_policy.this[each.key].id] : []. The output does not change.
🔧 **Test** - 2 issues found → auto-fixed ✅
  • 🚨 cloudflare-tunnel/terraform/main.tf:80 - A real terraform plan (cloudflare/cloudflare 5.26.0, the version ~> 5.0 resolves to) fails for every route whose access block has emails or email_domains. The error is Error: Value Conversion Error ... Received unknown value, however the target type cannot handle unknown values. Path: policies. The try()/for/if pattern on main.tf:80 makes the whole policies list unknown until the new policy exists. Consumers hit this on their first plan: aqaros-admin with a staff email list, and any emails-only or email_domains+policy_ids route. terraform test passes only because mock_provider skips the provider's unknown-value check. Fix, checked live with the same inputs (plans 5 to add, exit 0): contains(keys(local.access_policy_by_hostname), each.key) ? [cloudflare_zero_trust_access_policy.this[each.key].id] : [],. This is the rewrite from the round-1 finding policies-try-simplify. The owner declined that finding as a readability change, and this run left the decision alone. The new evidence shows it is a correctness bug, so the owner must re-decide. A mock-only test cannot catch this regression. CI needs a real-provider plan, or the owner accepts that gap.
  • 🚨 live validation verdict: no-go (11 of 11 scenarios were driven live against the product); failed: Consumer marks a route access-protected with a staff email and one identity provider id, runs terraform plan, and gets the app, the allow policy, and required=true on the ingress rule, Consumer protects a route with email_domains plus an existing policy id and plans
  • Live validation: ❌ no-go - 11 of 11 scenarios driven live against the product
Scenario Result Live Evidence
Consumer marks a route access-protected with a staff email and one identity provider id, runs terraform plan, and gets the app, the allow policy, and required=true on the ingress rule ❌ fail live emails-route-plan-fails-real-provider.txt: Error: Value Conversion Error ... Path: policies, exit 1, main.tf:78-83
Consumer protects a route with email_domains plus an existing policy id and plans ❌ fail live real-cli-plan-scenarios.txt === email_domains_plus_policy (exit=1)
Consumer with no access blocks bumps the module and plans exactly what main plans ✅ pass live backcompat-no-access-plan-diff.txt: main vs branch resource_changes IDENTICAL, origin_request null on every ingress rule
Consumer protects a route with only existing policy_ids and plans an app with no module-built policy ✅ pass live real-cli-plan-scenarios.txt === policy_ids_only (exit=0): dns_record, access_application, tunnel config, 3 to add
Consumer passes allowed_idps = [] or a blank id, and plan rejects it ✅ pass live real-cli-plan-scenarios.txt reject_empty_idps / reject_blank_idp: Invalid value for variable with the allowed_idps message
Consumer passes an empty access block or a wildcard (everyone) email, and plan rejects it ✅ pass live real-cli-plan-scenarios.txt reject_empty_access / reject_everyone_wildcard
Consumer protects one path of a hostname but not another, and plan rejects it ✅ pass live real-cli-plan-scenarios.txt reject_half_protected
Consumer adds an access block without access_team_name, and plan fails on the precondition ✅ pass live real-cli-plan-scenarios.txt reject_no_team_name: Resource precondition failed ... access_team_name is required
DNS record for a protected hostname waits for its Access application ✅ pass live dns-depends-on-graph.txt: graph edge cloudflare_dns_record.this -> cloudflare_zero_trust_access_application.this and plan configuration depends_on
Committed terraform test with mock_provider covers the no-access, one-access, and rejection cases ✅ pass live terraform-test.txt: Success! 8 passed, 0 failed
The cloudflare-tunnel tree holds only example.com placeholders ✅ pass live placeholder-grep.txt: every match is staff@example.com, no 32-hex ids
  • terraform -chdir=<copy of cloudflare-tunnel/terraform> test (mock_provider, 8 run blocks)
  • terraform plan on main (31b9149) and branch (b9b407d) with the same no-access routes, real provider, API base URL set to 127.0.0.1:9, then terraform show -json | jq diff of resource_changes
  • terraform plan on branch with one protected route (emails + allowed_idps = ["example-idp-id"]) using the real provider
  • terraform plan on a scratch copy with main.tf:80 rewritten to the contains() form, same inputs
  • terraform plan on branch: policy_ids-only, emails-only, email_domains+policy_ids
  • terraform plan on branch rejection cases: allowed_idps = [], allowed_idps = [" "], access = {}, wildcard email, half-protected hostname, missing access_team_name
  • terraform graph plus the plan JSON configuration depends_on for cloudflare_dns_record.this
  • git grep -nE "@|[0-9a-f]{32}" -- cloudflare-tunnel/
  • Tried terraform apply of main against a local fake Cloudflare API to prove a zero-change tag bump. The repo guard tf-guard.sh denied it, and this run did not work around the guard.

🔧 Fix applied.
✅ Re-checked - no issues remain.

  • Live validation: ✅ go - 11 of 12 scenarios driven live against the product
Scenario Result Live Evidence
Consumer protects a route with a staff email and one identity provider id and runs terraform plan. The plan creates the app and the allow policy, and each protected ingress rule gets required=true ✅ pass live r2-real-provider-plans.txt (emails_one_idp exit=0, 5 to add); r2-access-plan-details.txt (allowed_idps=[example-idp-id], auto_redirect_to_identity=true, one email rule and no email_domain rule, requir…
Consumer protects a route with email_domains plus an existing policy id and plans. The app lists the new policy first and the existing policy second ✅ pass live r2-real-provider-plans.txt (domains_plus_policy exit=0, 4 to add); r2-access-plan-details.txt (policies precedence 1 = module policy, precedence 2 = example-policy-id)
Consumer protects a route with policy_ids only. The plan creates the app with no new policy ✅ pass live r2-real-provider-plans.txt (policy_ids_only exit=0, 3 to add, no access_policy)
Consumer passes two identity provider ids. The app lists both, and auto redirect stays unset ✅ pass live r2-access-plan-details.txt (two_idps_no_redirect: allowed_idps has 2 ids, auto_redirect_to_identity=null)
Consumer only bumps the tag, with no access block. The plan matches main exactly, with no Access resources and empty access outputs ✅ pass live r2-backcompat-no-access.txt (diff of planned resource_changes main vs branch prints IDENTICAL; origin_request null; access_application_ids and access_aud_tags = {})
DNS record for a protected hostname depends on the Access application, so no host resolves early ✅ pass live r2-access-plan-details.txt dns_depends_on = [cloudflare_zero_trust_access_application.this]
Adversarial: allowed_idps = [] or a blank id is rejected at plan ✅ pass live r2-real-provider-plans.txt reject_empty_idps and reject_blank_idp exit=1 with the allowed_idps validation message
Adversarial: an empty access block, a wildcard everyone rule, or a half-protected hostname is rejected at plan ✅ pass live r2-real-provider-plans.txt reject_empty_access, reject_everyone_wildcard, reject_half_protected exit=1 with the named validation messages
Adversarial: an access route with no access_team_name fails the plan before the tunnel config is written ✅ pass live r2-real-provider-plans.txt reject_no_team_name exit=1, Resource precondition failed
Mock-provider terraform test file covers the no-access case, the one-access case, and the expect_failures cases ✅ pass live r2-terraform-test.txt (8 passed, exit=0)
Renaming a protected hostname moves the Access app and policy to the new hostname ⏸️ untested no The prior payload did not establish a live result. The rename check ran only as two mock applies through terraform test with mock_provider (r2-rename-mock-apply.txt). A real-provider apply needs real…
Repo holds placeholders only (git grep for @ and 32-hex prints only example.com) ✅ pass live r2-placeholder-grep.txt (lines not mentioning example.com: NONE)
  • terraform test (mock_provider "cloudflare", tests/access.tftest.hcl) in a disposable copy of cloudflare-tunnel/terraform
  • terraform plan -refresh=false with real cloudflare/cloudflare 5.26.0, a fake token and CLOUDFLARE_BASE_URL=http://127.0.0.1:9 for tfvars: emails_one_idp, domains_plus_policy, policy_ids_only, two_idps_no_redirect
  • terraform plan rejection cases: reject_empty_idps, reject_blank_idp, reject_empty_access, reject_everyone_wildcard, reject_half_protected, reject_no_team_name
  • terraform show -json <plan> | jq to read the planned app (allowed_idps, auto_redirect_to_identity, policies), the policy include rules, the ingress origin_request.access values, and the DNS depends_on
  • Same no-access tfvars planned on main 31b9149 and branch 8e928a2, then diff of the planned resource_changes
  • Throwaway tests/rename.tftest.hcl (harness only, not committed): mock apply with qr-old.example.com, then with qr-new.example.com
  • git grep -nE "@|[0-9a-f]{32}" -- cloudflare-tunnel/
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

pre-commit run --all-files fails on these files, which blocks the gate for every component.
…oute

Add an optional access object to each routes entry, plus access_team_name.
A protected hostname gets one self_hosted Access application and one allow
policy built from the consumer's emails and email_domains. Its ingress rule
gets origin_request.access with required = true so cloudflared refuses a
request without a valid Access JWT. DNS records wait for the applications.

Routes without access plan exactly as before. New outputs:
access_application_ids and access_aud_tags, keyed by hostname.
@github-actions

Copy link
Copy Markdown

📝 Changelog preview

Generated from this branch's Conventional Commits — this is what the next vX.Y.Z tag publishes verbatim to CHANGELOG.md and the GitHub Release (no manual curation). Tighten an entry by tightening its commit subject; scope it to the component.

[Unreleased]

Added

  • cloudflare-tunnel: Manage the Access app and allow policy per route
  • automation-roles: Allow EC2 in additional_regions beyond deploy_region

No-mistakes

  • test: Fix(cloudflare-tunnel): keep Access policies list known at plan

@opariffazman
opariffazman merged commit 68acdec into main Oct 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] officialdad/infra-components: cloudflare-tunnel: manage the Access app and allow policy per route

1 participant