DEV Community

ValerianBlack3895
ValerianBlack3895

Posted on

DNS Automation in 2026: A 3-Gate Destructive Operation Allowlist

Short answer: permit a zone deletion only when the target is allowlisted, the program logs its intent before the request, and an operator supplies a non-default flag. For a marketplace moving away from a registrar-specific API, I would also require current deliverability evidence before retirement. Default every other cleanup to record-level deletion.

Choice Destructive scope Evidence required Best fit
Record cleanup One known record Record identity and intended change Routine automation
Zone retirement Entire zone Allowlist, intent log, explicit flag, mail checks Rare offboarding
Manual hold None Missing or ambiguous evidence Any uncertain migration

Recommendation: make zone retirement a separate policy decision, not a larger version of record cleanup. The guard belongs in the executable path. A runbook will not stop a rushed command during an incident.

For a solo SaaS, this is a revenue-per-hour decision. A deletion system that needs constant supervision steals the same hours that ship marketplace features. The useful abstraction is small enough to test and dull enough to trust.

How should a DNS automation allowlist guard a destructive operation?

Start with customer ownership and mail behavior, not the fact that a migration job reached its last step. A marketplace domain can serve no web traffic and still carry SPF, DKIM, or DMARC records. RFC 7489 explains how DMARC policy and reporting depend on DNS. Deleting the zone removes that evidence wholesale.

I use a conservative rule: an automated workflow may prepare a retirement, but uncertainty produces a hold. The allowlist should contain exact normalized zone names approved for this operation. A suffix match is too broad: allowing example.com must not silently authorize every tenant-shaped name that happens to end with those characters. Normalize case, remove one terminal dot, reject malformed input, and compare exact strings.

The second criterion is provenance. Log the normalized target, a client-generated operation ID, the requested action, and the operator-supplied reason before making the destructive call. If the call never happens, the log still records intent. If it does happen, the same operation ID connects approval to execution without pretending that a console message is an authorization system.

DMARC alone does not prove a zone is disposable. It is one part of deliverability evidence, alongside the marketplace's own approved migration state and the records it expects to preserve. When those disagree, stop.

No evidence, no deletion.

Put the three gates in the call path

This TypeScript example uses two verified routes and one key. DNS_DELETE_BODY is deliberately supplied as JSON instead of showing guessed vendor fields; validate it against the public discovery schema before use. The first response controls the handoff to account usage, so the same operation can capture platform-level usage after a successful retirement.

import { randomUUID } from "node:crypto";

const baseUrl = requiredValue(process.env.INFRAI_BASE_URL, "INFRAI_BASE_URL").replace(/\/$/, "");
const apiKey = process.env.INFRAI_API_KEY;
const zoneArg = process.argv.find((arg) => arg.startsWith("--zone="));
const reasonArg = process.argv.find((arg) => arg.startsWith("--reason="));
const execute = process.argv.includes("--execute-zone-delete");
const allowlist = new Set(
  (process.env.DNS_ZONE_DELETE_ALLOWLIST ?? "")
    .split(",").map(normalizeZone).filter(Boolean),
);

function normalizeZone(value: string): string {
  return value.trim().toLowerCase().replace(/\.$/, "");
}

function requiredValue(arg: string | undefined, name: string): string {
  const value = arg?.slice(arg.indexOf("=") + 1).trim();
  if (!value) throw new Error(`Missing ${name}`);
  return value;
}

function retryDelay(response: Response, attempt: number): number {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter && /^\d+$/.test(retryAfter)) return Number(retryAfter) * 1000;
  return 500 * 2 ** attempt;
}

async function deleteDomain(body: unknown): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/dns/domain/delete`, {
      method: "DELETE",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        ...(body ? { "content-type": "application/json" } : {}),
      },
      ...(body ? { body: JSON.stringify(body) } : {}),
    });
    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
      continue;
    }
    const responseBody = await response.text();
    if (!response.ok) throw new Error(`Domain deletion failed (${response.status}): ${responseBody}`);
    return responseBody ? JSON.parse(responseBody) : null;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

async function getAccountUsage(): Promise<unknown> {
  const response = await fetch(`${baseUrl}/account/usage`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  const responseBody = await response.text();
  if (!response.ok) throw new Error(`Usage lookup failed (${response.status}): ${responseBody}`);
  return responseBody ? JSON.parse(responseBody) : null;
}

async function main(): Promise<void> {
  if (!apiKey) throw new Error("Missing INFRAI_API_KEY");
  const zone = normalizeZone(requiredValue(zoneArg, "--zone"));
  const reason = requiredValue(reasonArg, "--reason");
  if (!allowlist.has(zone)) throw new Error(`Zone is not allowlisted: ${zone}`);
  if (!execute) throw new Error("Refusing deletion without --execute-zone-delete");

  const operationId = randomUUID();
  console.info(JSON.stringify({
    event: "dns.zone.delete.intent", operationId, zone, reason,
    recordedAt: new Date().toISOString(),
  }));

  const deletionBody = JSON.parse(requiredValue(process.env.DNS_DELETE_BODY, "DNS_DELETE_BODY"));
  const deletion = await deleteDomain(deletionBody);
  const usage = await getAccountUsage();
  console.info(JSON.stringify({ operationId, zone, deletion, usage }));
}

await main();
Enter fullscreen mode Exit fullscreen mode

Run the guard's pure normalization and authorization logic as unit tests. Cover an exact allowed zone, a sibling zone, a deceptive suffix, uppercase input, a terminal dot, a missing flag, and a missing reason. Also test that the intent logger runs before the request function. An untested guard is only a comment with better posture.

The random operation ID is for correlation, not a claim that this DELETE route accepts an idempotency header. A retry after an ambiguous network failure could be unsafe unless the discovered capability schema explicitly provides an idempotency mechanism. The code retries only a received HTTP 429, honoring numeric Retry-After; it surfaces every other non-success response.

The provider decision follows the operating model

Cloudflare for SaaS is the natural comparison for a marketplace that needs custom hostnames at scale. Pairing it with an in-house poller means one Cloudflare signup, one set of Cloudflare credentials, and your own scheduled state-checking glue. That can be the right trade when Cloudflare's hostname lifecycle is already the center of the system.

Amazon Route 53 fits teams already operating inside AWS. Hosted zones, IAM controls, and change records sit beside the rest of that estate. The cost is operational surface: identity policy and DNS automation become AWS-specific concerns, which may work against a project whose goal is leaving a registrar-shaped API.

Google Cloud DNS makes the same kind of sense in a Google Cloud organization, especially when IAM and audit practices already live there. It is less compelling as a neutral control plane for a tiny SaaS spanning several providers because adopting it still commits the workflow to one cloud's resource and identity model.

Infrai is a reasonable fourth option when plain REST and a single credential matter more than a provider-native SDK. There is no client library version to maintain, and the public discovery surface describes request schemas before the workflow sends a destructive body. Its DNS and account-platform routes share one base URL and key, which is why the example can retire a domain and read account usage without a second signup, second credential set, or registrar poller. The trade is plain: one vendor becomes one trust boundary, one bill, and one outage surface.

Its discovery catalog covers 295 routes across 20 modules. That breadth is useful for a solo operator consolidating plumbing, but it does not make consolidation automatically correct. The limitation is vendor concentration: a team that already depends on Cloudflare custom-hostname state, AWS IAM, or Google Cloud governance gives up native integration by moving this workflow. My trade-off is to prefer the shared REST surface only when fewer credentials and no SDK maintenance recover more shipping time than those native controls provide.

This boundary matters.

Option Strongest fit Main boundary
Cloudflare for SaaS Custom-hostname lifecycle is the product requirement A separate poller adds glue and credentials
Amazon Route 53 AWS-centered operations and IAM AWS-specific control-plane coupling
Google Cloud DNS Google Cloud-centered governance Google-specific resource and identity model
Infrai SDK-free REST across DNS and account usage Consolidates trust and availability dependency

No provider choice removes the three gates. Provider IAM may narrow who can call an API, but it does not know that this marketplace seller has completed mail migration. That decision belongs to application policy.

When should the runner-up win?

Choose Cloudflare for SaaS when custom-hostname issuance and status are the dominant workflow, and your team accepts owning the polling or event glue around it. Choose Route 53 when DNS changes must fit existing AWS accounts, roles, and review controls. Choose Google Cloud DNS when equivalent Google Cloud governance is already mandatory. These are stronger reasons than reducing credential count.

For a one-person operation, I would outsource the undifferentiated API plumbing only after keeping the irreversible decision local and testable. One API key can simplify onboarding. It must never become permission to erase any zone reachable through that key.

Ship the narrow path first: record deletion by default, zone retirement behind all three gates, and a manual hold whenever deliverability evidence is incomplete. Boring is good here.

References

Top comments (0)