Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions catalog/permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 15 additions & 2 deletions catalog/surfaces.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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 <operation-id>"
]
},
"d1.database": {
Expand Down
10 changes: 10 additions & 0 deletions commands/cfctl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)" \
Expand Down
4 changes: 2 additions & 2 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `-` |
Expand Down Expand Up @@ -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` | - |
Expand Down Expand Up @@ -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` |
Expand Down
10 changes: 10 additions & 0 deletions scripts/cf_maildesk_cf_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}:
Expand Down Expand Up @@ -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:
Expand All @@ -768,6 +777,7 @@ def append_sender_checks(
"Sender-provider domain is not verified",
"verified",
provider_status,
sender_domain_enable_plan_command(domain),
)
)

Expand Down
104 changes: 104 additions & 0 deletions scripts/cf_mutate_sender_domain.sh
Original file line number Diff line number Diff line change
@@ -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="${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}"
2 changes: 1 addition & 1 deletion scripts/verify_maildesk_cf_contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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="$(
Expand Down
5 changes: 3 additions & 2 deletions scripts/verify_permission_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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' "
Expand Down
8 changes: 7 additions & 1 deletion scripts/verify_static_contract.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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 == ["*"]
Expand Down Expand Up @@ -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
Expand Down
Loading