diff --git a/catalog/permissions.json b/catalog/permissions.json index 903589f..82bc5ee 100644 --- a/catalog/permissions.json +++ b/catalog/permissions.json @@ -289,6 +289,12 @@ "surfaces": ["sender_domain"], "profiles": ["read", "deploy", "full-operator"] }, + { + "name": "Email Sending Write", + "scope": "zone", + "surfaces": ["sender_domain"], + "profiles": ["deploy", "full-operator"] + }, { "name": "Turnstile Sites Read", "scope": "account", diff --git a/catalog/surfaces.json b/catalog/surfaces.json index ed4e77f..898531e 100644 --- a/catalog/surfaces.json +++ b/catalog/surfaces.json @@ -543,6 +543,7 @@ "description": "Cloudflare Email Sending sender subdomains in a specific zone.", "backend": "inventory_script", "inventory_script": "scripts/cf_inventory_sender_domains.sh", + "apply_script": "scripts/cf_mutate_sender_domain.sh", "permission_family": "Email Sending", "selectors": ["id", "name", "zone"], "actions": { @@ -565,12 +566,24 @@ "required_selectors": ["zone"] }, "apply": { - "supported": false + "supported": true, + "risk": "write", + "preview_required": true, + "verification_required": true, + "operations": { + "enable": { + "risk": "write", + "required_selectors": ["zone", "name"], + "public_example": "cfctl apply sender_domain enable --zone example.com --name example.com --plan" + } + } } }, "examples": [ "./cfctl list sender_domain --zone example.com", - "./cfctl get sender_domain --zone example.com --name example.com" + "./cfctl get sender_domain --zone example.com --name example.com", + "./cfctl apply sender_domain enable --zone example.com --name example.com --plan", + "./cfctl apply sender_domain enable --zone example.com --name example.com --ack-plan " ] }, "d1.database": { diff --git a/commands/cfctl.sh b/commands/cfctl.sh index 6c1de2e..25f9de8 100644 --- a/commands/cfctl.sh +++ b/commands/cfctl.sh @@ -3895,6 +3895,16 @@ cfctl_handle_apply() { "BODY_JSON=${CFCTL_BODY_JSON}" \ "BODY_FILE=${CFCTL_BODY_FILE}" ;; + sender_domain) + cfctl_run_backend_script "${script_path}" \ + "APPLY=$([[ "${CFCTL_PLAN}" == "1" ]] && echo 0 || echo 1)" \ + "OPERATION=${operation}" \ + "ZONE_NAME=${CFCTL_ZONE_NAME}" \ + "ZONE_ID=${CFCTL_ZONE_ID}" \ + "SENDER_DOMAIN=${CFCTL_NAME}" \ + "BODY_JSON=${CFCTL_BODY_JSON}" \ + "BODY_FILE=${CFCTL_BODY_FILE}" + ;; zone.ruleset) cfctl_run_backend_script "${script_path}" \ "APPLY=$([[ "${CFCTL_PLAN}" == "1" ]] && echo 0 || echo 1)" \ diff --git a/docs/capabilities.md b/docs/capabilities.md index 2b85e90..7ce9f1d 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -27,7 +27,7 @@ This table is the operable runtime surface. The standards layer and docs bank in | `queue` | yes | yes | no | yes | no | `-` | `-` | `-` | | `r2.bucket` | yes | yes | no | yes | no | `-` | `-` | `-` | | `security.txt` | yes | yes | yes | yes | yes | `security.txt` | `security-center-securitytxt, api-auth` | `security_txt` | -| `sender_domain` | yes | yes | no | yes | no | `-` | `-` | `-` | +| `sender_domain` | yes | yes | yes | yes | no | `-` | `-` | `-` | | `tunnel` | yes | yes | yes | yes | yes | `tunnel` | `api-auth` | `tunnel` | | `turnstile.widget` | yes | yes | yes | yes | no | `-` | `-` | `-` | | `vulnerability_scanner.credential_set` | yes | yes | no | yes | no | `-` | `api-shield-vulnerability-scanner, api-auth` | `-` | @@ -79,6 +79,7 @@ This matrix is derived from the same catalogs used by `cfctl explain`, `cfctl cl | `security.txt` | `delete` | `destructive` | yes | `lease` | yes | `delete` | `dev`, `global` | required: zone | | `security.txt` | `upsert` | `write` | yes | `apply` | yes | `-` | `dev`, `global` | required: zone | | `security.txt` | `sync` | `write` | yes | `apply` | yes | `-` | `dev`, `global` | state match: zone | +| `sender_domain` | `enable` | `write` | yes | `apply` | yes | `-` | `dev`, `global` | required: zone, name | | `tunnel` | `cleanup-connections` | `destructive` | yes | `lease` | yes | `delete` | `dev`, `global` | required: id | | `tunnel` | `configure` | `write` | yes | `apply` | yes | `-` | `dev`, `global` | required: id | | `tunnel` | `create` | `write` | yes | `apply` | yes | `-` | `dev`, `global` | - | @@ -116,7 +117,6 @@ These surfaces are first-class read surfaces but do not expose `apply` or desire | `pages.project` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_pages.sh` | | `queue` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_queues.sh` | | `r2.bucket` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_r2.sh` | -| `sender_domain` | `list`, `get`, `verify`, `can` | required: zone | `scripts/cf_inventory_sender_domains.sh` | | `vulnerability_scanner.credential_set` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_vulnerability_scanner.sh` | | `vulnerability_scanner.scan` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_vulnerability_scanner.sh` | | `vulnerability_scanner.target_environment` | `list`, `get`, `verify`, `can` | - | `scripts/cf_inventory_vulnerability_scanner.sh` | diff --git a/scripts/cf_maildesk_cf_lifecycle.py b/scripts/cf_maildesk_cf_lifecycle.py index b72234e..c9594a9 100644 --- a/scripts/cf_maildesk_cf_lifecycle.py +++ b/scripts/cf_maildesk_cf_lifecycle.py @@ -464,6 +464,14 @@ def sender_readback_available(sender: dict[str, Any]) -> bool: return provider_readback not in {"", "not_available", "unavailable", "unknown"} +def sender_domain_enable_plan_command(domain: str) -> str: + quoted_domain = shlex.quote(domain) + return ( + "cfctl apply sender_domain enable " + f"--zone {quoted_domain} --name {quoted_domain} --plan" + ) + + def normalize_sender_mode(mode: str) -> str: normalized = (mode or "disabled").lower() if normalized in {"cloudflare_first", "cloudflare", "cloudflare_email_service"}: @@ -757,6 +765,7 @@ def append_sender_checks( "Sender-provider domain is not present in readback", "verified", {"provider": mode, "domains": sender_evidence.get("domains") or []}, + sender_domain_enable_plan_command(domain), ) ) elif not provider_verified: @@ -768,6 +777,7 @@ def append_sender_checks( "Sender-provider domain is not verified", "verified", provider_status, + sender_domain_enable_plan_command(domain), ) ) diff --git a/scripts/cf_mutate_sender_domain.sh b/scripts/cf_mutate_sender_domain.sh new file mode 100755 index 0000000..f7e8420 --- /dev/null +++ b/scripts/cf_mutate_sender_domain.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash + +# Mutates Cloudflare Email Sending sender subdomains. +# +# enable: POST /zones/:zone_id/email/sending/subdomains +# +# Required env from cfctl: +# OPERATION enable +# ZONE_NAME or ZONE_ID +# SENDER_DOMAIN subdomain/domain name to enable for Email Sending + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC1091 +source "${ROOT_DIR}/scripts/lib/cloudflare.sh" + +cf_load_cloudflare_env +cf_require_tools jq +cf_require_api_auth +cf_require_backend_dispatch "cfctl apply sender_domain ..." + +OPERATION="${OPERATION:-enable}" +ZONE_NAME="${ZONE_NAME:-}" +ZONE_ID="${ZONE_ID:-}" +SENDER_DOMAIN="${SENDER_DOMAIN:-${DOMAIN_NAME:-}}" + +if [[ -z "${ZONE_ID}" ]]; then + if [[ -z "${ZONE_NAME}" ]]; then + echo "ZONE_NAME or ZONE_ID must be set" >&2 + exit 1 + fi + ZONE_ID="$(cf_resolve_zone_id "${ZONE_NAME}")" +fi + +if [[ -z "${ZONE_ID}" || "${ZONE_ID}" == "null" ]]; then + echo "Unable to resolve zone" >&2 + exit 1 +fi + +if [[ -z "${SENDER_DOMAIN}" ]]; then + echo "SENDER_DOMAIN must be set" >&2 + exit 1 +fi + +if [[ -n "${ZONE_NAME}" ]]; then + zone_lc="$(printf '%s' "${ZONE_NAME}" | tr '[:upper:]' '[:lower:]')" + sender_lc="$(printf '%s' "${SENDER_DOMAIN}" | tr '[:upper:]' '[:lower:]')" + if [[ "${sender_lc}" != "${zone_lc}" && "${sender_lc}" != *".${zone_lc}" ]]; then + echo "SENDER_DOMAIN must be within ZONE_NAME" >&2 + exit 1 + fi +fi + +build_payload() { + if [[ -n "${BODY_JSON:-}" || -n "${BODY_FILE:-}" ]]; then + cf_resolve_json_payload "${BODY_JSON:-}" "${BODY_FILE:-}" + return + fi + + jq -n --arg name "${SENDER_DOMAIN}" '{name: $name}' +} + +export SURFACE="sender-domain" +export OUTPUT_STEM="sender-domain-mutation" +export APPLY="${APPLY:-0}" + +case "${OPERATION}" in + enable) + export REQUEST_METHOD="POST" + export REQUEST_PATH="/zones/${ZONE_ID}/email/sending/subdomains" + export VERIFY_PATH="/zones/${ZONE_ID}/email/sending/subdomains" + export BODY_JSON="$(build_payload)" + ;; + *) + echo "Unsupported OPERATION: ${OPERATION}" >&2 + exit 1 + ;; +esac + +set +e +mutation_report="$("${ROOT_DIR}/scripts/cf_api_apply.sh")" +status=$? +set -e +printf '%s\n' "${mutation_report}" + +report_file="$(printf '%s\n' "${mutation_report}" | tail -n 1)" +if [[ "${APPLY}" == "1" && "${status}" -eq 0 && -f "${report_file}" ]]; then + if jq -e --arg name "${SENDER_DOMAIN}" ' + (.verification.response.result // []) + | any( + .[]?; + (((.name // .domain // "") | ascii_downcase) == ($name | ascii_downcase)) + and (.enabled == true) + ) + ' "${report_file}" >/dev/null; then + exit 0 + fi + + echo "Email Sending sender-domain verification failed for ${SENDER_DOMAIN}" >&2 + exit 1 +fi + +exit "${status}" diff --git a/scripts/verify_maildesk_cf_contract.sh b/scripts/verify_maildesk_cf_contract.sh index 74d2f07..f0a03de 100755 --- a/scripts/verify_maildesk_cf_contract.sh +++ b/scripts/verify_maildesk_cf_contract.sh @@ -178,7 +178,7 @@ jq -e ' and .readiness.mail_ready == false and (.drift_classes | index("sender_domain_drift")) != null and (.drift_classes | index("provider_status_unavailable")) == null - and any(.plan.operations[]; .surface == "sender_domain" and .blocked == "sender-domain authentication is not yet a cfctl mutation surface") + and any(.plan.operations[]; .surface == "sender_domain" and .preview_command == "cfctl apply sender_domain enable --zone example.com --name example.com --plan" and .blocked == null) ' "${unverified_sender_artifact_path}" >/dev/null || die "unverified sender-domain drift contract did not match" cfctl_output="$( diff --git a/scripts/verify_permission_catalog.py b/scripts/verify_permission_catalog.py index 286dff2..0d2eff5 100644 --- a/scripts/verify_permission_catalog.py +++ b/scripts/verify_permission_catalog.py @@ -322,8 +322,9 @@ def validate_command_fixtures(catalog: dict) -> list[dict]: "expected": ( "cfctl token mint --name 'cfctl-deploy-operator' --permission 'Account Settings Read' " "--permission 'D1 Metadata Read' --permission 'D1 Read' --permission 'D1 Write' " - "--permission 'Email Sending Read' --permission 'Pages Read' --permission 'Pages Write' " - "--permission 'Queues Read' --permission 'Queues Write' --permission 'Workers R2 Storage Read' " + "--permission 'Email Sending Read' --permission 'Email Sending Write' " + "--permission 'Pages Read' --permission 'Pages Write' --permission 'Queues Read' " + "--permission 'Queues Write' --permission 'Workers R2 Storage Read' " "--permission 'Workers R2 Storage Write' --permission 'Workers Routes Read' " "--permission 'Workers Routes Write' --permission 'Workers Scripts Read' " "--permission 'Workers Scripts Write' --permission 'Zone Read' " diff --git a/scripts/verify_static_contract.sh b/scripts/verify_static_contract.sh index 9dd5457..994e4c9 100755 --- a/scripts/verify_static_contract.sh +++ b/scripts/verify_static_contract.sh @@ -105,6 +105,7 @@ bash -n \ "${ROOT_DIR}/scripts/cf_inventory_edge_certificates.sh" \ "${ROOT_DIR}/scripts/cf_inventory_zone_settings.sh" \ "${ROOT_DIR}/scripts/cf_inventory_security_txt.sh" \ + "${ROOT_DIR}/scripts/cf_mutate_sender_domain.sh" \ "${ROOT_DIR}/scripts/cf_mutate_access_login_method.sh" \ "${ROOT_DIR}/scripts/cf_mutate_email_routing_rule.sh" \ "${ROOT_DIR}/scripts/cf_mutate_edge_certificate.sh" \ @@ -302,6 +303,7 @@ assert_jq_file "permission profile minimality policy" ' and (.profiles["security-audit"].allowed_surfaces | index("zone.setting")) != null and (.permissions[] | select(.name == "Zone Settings Read" and .scope == "zone" and (.surfaces | index("zone.setting")) != null)) and (.permissions[] | select(.name == "Zone Settings Write" and .scope == "zone" and (.profiles | index("hostname")) != null)) + and (.permissions[] | select(.name == "Email Sending Write" and .scope == "zone" and (.surfaces | index("sender_domain")) != null and (.profiles | index("deploy")) != null)) and (.profiles.deploy.allowed_surfaces | index("audit.log")) != null and (.profiles.deploy.allowed_surfaces | index("wrangler")) != null and .profiles["full-operator"].allowed_surfaces == ["*"] @@ -720,10 +722,14 @@ assert_jq_file "surface module bindings" ' and (.surfaces["maildesk-cf"].docs_topics | index("email-routing")) != null and .surfaces["sender_domain"].inventory_script == "scripts/cf_inventory_sender_domains.sh" and .surfaces["sender_domain"].permission_family == "Email Sending" + and .surfaces["sender_domain"].apply_script == "scripts/cf_mutate_sender_domain.sh" and .surfaces["sender_domain"].actions.list.required_selectors == ["zone"] and .surfaces["sender_domain"].actions.get.selectors_any_of == [["id"], ["name"]] and .surfaces["sender_domain"].actions.verify.selectors_any_of == [["id"], ["name"]] - and .surfaces["sender_domain"].actions.apply.supported == false + and .surfaces["sender_domain"].actions.apply.supported == true + and .surfaces["sender_domain"].actions.apply.preview_required == true + and .surfaces["sender_domain"].actions.apply.verification_required == true + and .surfaces["sender_domain"].actions.apply.operations.enable.required_selectors == ["zone", "name"] and .surfaces["worker.route"].module == "worker_route" and .surfaces["worker.route"].standards_ref == "worker.route" and (.surfaces["worker.route"].docs_topics | index("workers-routes")) != null