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
8 changes: 8 additions & 0 deletions catalog/permissions.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"pages.project",
"queue",
"r2.bucket",
"sender_domain",
"tunnel",
"turnstile.widget",
"vulnerability_scanner.credential_set",
Expand Down Expand Up @@ -119,6 +120,7 @@
"pages.project",
"queue",
"r2.bucket",
"sender_domain",
"worker.route",
"worker.script",
"worker.secret",
Expand Down Expand Up @@ -281,6 +283,12 @@
"surfaces": ["wrangler"],
"profiles": ["deploy", "full-operator"]
},
{
"name": "Email Sending Read",
"scope": "zone",
"surfaces": ["sender_domain"],
"profiles": ["read", "deploy", "full-operator"]
},
{
"name": "Turnstile Sites Read",
"scope": "account",
Expand Down
34 changes: 34 additions & 0 deletions catalog/surfaces.json
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,40 @@
"./cfctl maildesk-cf provision --file state/maildesk-cf/example.json --plan"
]
},
"sender_domain": {
"description": "Cloudflare Email Sending sender subdomains in a specific zone.",
"backend": "inventory_script",
"inventory_script": "scripts/cf_inventory_sender_domains.sh",
"permission_family": "Email Sending",
"selectors": ["id", "name", "zone"],
"actions": {
"list": {
"supported": true,
"required_selectors": ["zone"]
},
"get": {
"supported": true,
"required_selectors": ["zone"],
"selectors_any_of": [["id"], ["name"]]
},
"verify": {
"supported": true,
"required_selectors": ["zone"],
"selectors_any_of": [["id"], ["name"]]
},
"can": {
"supported": true,
"required_selectors": ["zone"]
},
"apply": {
"supported": false
}
},
"examples": [
"./cfctl list sender_domain --zone example.com",
"./cfctl get sender_domain --zone example.com --name example.com"
]
},
"d1.database": {
"description": "D1 databases visible to the current account token.",
"backend": "inventory_script",
Expand Down
2 changes: 2 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +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 | `-` | `-` | `-` |
| `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 @@ -115,6 +116,7 @@ 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
81 changes: 81 additions & 0 deletions scripts/cf_inventory_sender_domains.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env bash

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 curl jq
cf_require_api_auth
cf_setup_log_pipe "inventory-sender-domains" "build"

ZONE_NAME="${ZONE_NAME:-}"
ZONE_ID="${ZONE_ID:-}"

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 "${ZONE_NAME}" ]]; then
ZONE_NAME="$(cf_api GET "/zones/${ZONE_ID}" | jq -r '.result.name // empty')"
fi

SUBDOMAINS_JSON="$(cf_api_capture GET "/zones/${ZONE_ID}/email/sending/subdomains")"
OUTPUT_FILE="$(cf_inventory_file "email-sending" "sender-domains")"

REPORT_JSON="$(
jq -n \
--arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--arg zone_id "${ZONE_ID}" \
--arg zone_name "${ZONE_NAME}" \
--argjson subdomains "${SUBDOMAINS_JSON}" \
'
($subdomains.result // []) as $items
| {
generated_at: $generated_at,
zone: {
id: $zone_id,
name: $zone_name
},
sender_domains: (
$items
| map(
. + {
zone_id: $zone_id,
zone_name: $zone_name,
domain: (.name // null),
provider: "cloudflare_email_service",
verified: (.enabled // false),
status: (if (.enabled // false) then "verified" else "pending" end)
}
)
),
response: $subdomains,
summary: {
readable: ($subdomains.success // false),
sender_domain_count: ($items | length),
verified_count: ($items | map(select(.enabled == true)) | length),
names: ($items | map(.name // .id // null) | map(select(. != null)) | sort),
error_count: (($subdomains.errors // []) | length)
}
}
'
)"

cf_write_json_file "${OUTPUT_FILE}" "${REPORT_JSON}"

echo "Captured Email Sending sender domains for ${ZONE_NAME}."
echo "${REPORT_JSON}" | jq '.summary'
cf_print_log_footer
echo "${OUTPUT_FILE}"
41 changes: 38 additions & 3 deletions scripts/cf_maildesk_cf_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,31 @@ def load_fixture_evidence(path: Path, domains: list[dict[str, Any]]) -> dict[str


def collect_live_evidence(domains: list[dict[str, Any]]) -> dict[str, Any]:
sender_domains: list[dict[str, Any]] = []
sender_errors: dict[str, Any] = {}
evidence: dict[str, Any] = {
"worker.script": run_cfctl(["list", "worker.script"]),
"d1.database": run_cfctl(["list", "d1.database"]),
"r2.bucket": run_cfctl(["list", "r2.bucket"]),
"queue": run_cfctl(["list", "queue"]),
"domains": {},
"sender": {"provider_readback": "not_available"},
"sender": {
"provider": "cloudflare_email_service",
"domains": sender_domains,
"errors": sender_errors,
},
}
for domain_spec in domains:
domain = str(domain_spec.get("name") or "")
sender_payload = run_cfctl(["list", "sender_domain", "--zone", domain], lane="global")
if sender_payload.get("ok") is True:
sender_domains.extend(items(sender_payload))
else:
sender_errors[domain] = {
"command": sender_payload.get("_command"),
"error": sender_payload.get("error"),
"returncode": sender_payload.get("_returncode"),
}
evidence["domains"][domain] = {
"email.routing_rule": run_cfctl(["list", "email.routing_rule", "--zone", domain], lane="global"),
"email.routing": {},
Expand Down Expand Up @@ -442,6 +457,13 @@ def sender_domain_status(sender: dict[str, Any], domain: str) -> dict[str, Any]
return None


def sender_readback_available(sender: dict[str, Any]) -> bool:
if "domains" in sender or "authenticated_domains" in sender:
return True
provider_readback = str(sender.get("provider_readback") or "").lower()
return provider_readback not in {"", "not_available", "unavailable", "unknown"}


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 @@ -715,7 +737,7 @@ def append_sender_checks(
[dns_name(record) for record in dns_items],
)
)
if domain_status is None:
if domain_status is None and not sender_readback_available(sender_evidence):
drifts.append(
drift(
"provider_status_unavailable",
Expand All @@ -726,6 +748,17 @@ def append_sender_checks(
None,
)
)
elif domain_status is None:
drifts.append(
drift(
"sender_domain_drift",
"error",
f"sender_domain:{domain}",
"Sender-provider domain is not present in readback",
"verified",
{"provider": mode, "domains": sender_evidence.get("domains") or []},
)
)
elif not provider_verified:
drifts.append(
drift(
Expand Down Expand Up @@ -811,7 +844,9 @@ def planned_operations(drifts: list[dict[str, Any]]) -> list[dict[str, Any]]:
operation["blocked"] = "resource creation must use the owning primitive cfctl surface or app deploy lane"
elif drift_class == "wrong_binding":
operation["blocked"] = "Worker bindings are owned by the app repo config and deploy lane"
elif drift_class in {"dns_authentication_drift", "sender_domain_drift", "provider_status_unavailable"}:
elif drift_class == "sender_domain_drift":
operation["blocked"] = "sender-domain authentication is not yet a cfctl mutation surface"
elif drift_class in {"dns_authentication_drift", "provider_status_unavailable"}:
operation["blocked"] = "sender-domain authentication/provider readback is not yet a cfctl mutation surface"
elif drift_class == "policy_config_drift":
operation["blocked"] = "policy config is owned by the app repo"
Expand Down
22 changes: 22 additions & 0 deletions scripts/lib/cfctl.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,12 @@ cfctl_collect_surface_items() {
cfctl_run_backend_script "${script_path}" "ZONE_NAME=${CFCTL_ZONE_NAME}" "ZONE_ID=${CFCTL_ZONE_ID}"
CFCTL_COLLECT_BACKEND="inventory_script"
;;
sender_domain)
cfctl_resolve_zone_context
script_path="${CF_REPO_ROOT}/scripts/cf_inventory_sender_domains.sh"
cfctl_run_backend_script "${script_path}" "ZONE_NAME=${CFCTL_ZONE_NAME}" "ZONE_ID=${CFCTL_ZONE_ID}"
CFCTL_COLLECT_BACKEND="inventory_script"
;;
zone.ruleset)
cfctl_resolve_zone_context
script_path="${CF_REPO_ROOT}/scripts/cf_inventory_zone_security.sh"
Expand Down Expand Up @@ -1707,6 +1713,9 @@ cfctl_collect_surface_items() {
' <<< "${CFCTL_BACKEND_ARTIFACT_JSON}"
)"
;;
sender_domain)
CFCTL_COLLECT_ITEMS_JSON="$(jq -c '.sender_domains // []' <<< "${CFCTL_BACKEND_ARTIFACT_JSON}")"
;;
zone.ruleset)
CFCTL_COLLECT_ITEMS_JSON="$(
jq -c '
Expand Down Expand Up @@ -2002,6 +2011,18 @@ cfctl_filter_surface_items() {
]
' <<< "${items_json}"
;;
sender_domain)
jq -c --arg id "${CFCTL_ID}" --arg name "${CFCTL_NAME}" '
[
.[]
| select(
(if $id != "" then .id == $id else true end)
and
(if $name != "" then ((.name // .domain // "") | ascii_downcase) == ($name | ascii_downcase) else true end)
)
]
' <<< "${items_json}"
;;
zone.ruleset)
jq -c --arg id "${CFCTL_ID}" --arg name "${CFCTL_NAME}" --arg phase "${CFCTL_PHASE:-}" '
[
Expand Down Expand Up @@ -2285,6 +2306,7 @@ cfctl_summary_for_items() {
worker.secret) name_field="name" ;;
worker.route) name_field="pattern" ;;
email.routing_rule) name_field="recipient" ;;
sender_domain) name_field="name" ;;
zone.ruleset) name_field="name" ;;
security.txt) name_field="zone_name" ;;
d1.database) name_field="name" ;;
Expand Down
Loading
Loading