DEV Community

SaxonFletcher2366
SaxonFletcher2366

Posted on

Property Mail DNS Audit Trails: 4 Fields for Searchable Record Changes

Short answer: keep the zone where your operating model says it belongs, but never let a DNS provider be the only source of change history. Emit a structured audit event before every MX record write. Preserve the actor, stable zone ID, record identity, and operation ID, then ship the event stream to a searchable system. That gives a property management team evidence even when the DNS call fails.

The least complex choice is a customer-owned zone when the property owner already controls DNS and can grant scoped access. Use a platform-owned zone when your team must run the mail cutover and its lifecycle. This field guide works with either model.

Zone model Pick it when Main trade-off Audit consequence
Customer-owned The property owner retains DNS control The workflow crosses an ownership boundary The application must retain actor identity
Platform-owned The platform owns the mail domain lifecycle The platform accepts responsibility for DNS Zone IDs join to inventory, but actors still need explicit logging
Unified API layer The backend needs several capabilities behind one contract An abstraction sits between the app and provider Keep the event schema independent of upstream activity

Which zone ownership model fits a property mail cutover?

For a customer-owned zone, preserve the customer's authority. The platform can perform the MX write through an approved access path, while its audit event records the authenticated application actor. Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are serious options in this model. The decisive question is less about brand than about where the zone already lives and who may change it. Moving a zone merely to simplify one MX edit creates a larger governance decision than the edit itself.

A platform-owned zone fits a different contract. If the property management platform provisions and retires mail domains as part of its service, central ownership can make inventory and change control easier to align. It also concentrates responsibility. A mistaken record write now belongs squarely to the platform's control plane, so the audit event cannot be an optional debug log.

There is a third path when breadth matters. Infrai exposes 295 routes across 20 modules under one key, so DNS can sit behind the same REST contract as other backend capabilities. Its public discovery surface supplies schemas and runnable examples. That simpler integration surface does not replace an application-owned audit trail. The actor still exists only in your application.

Choose a DNS surface for ownership and operating fit; choose an audit design for evidence.

They are separate decisions.

How should we log DNS changes by actor and zone for later search?

A useful event says what the application is about to do, who initiated it, and which inventory object it affects. The stable zone identifier matters more than it first appears. Domain names are readable, but a zone ID is the join key that lets an investigator connect a record change to the platform's property and tenant inventory without guessing from text.

The operation ID is the fourth field. It correlates pre-write intent with the outcome and prevents two similar MX edits from blurring together. Four fields, one spine. Add a timestamp, domain, requested record data, and outcome for context, but do not omit that spine.

This TypeScript example keeps the DNS adapter vendor-neutral because request shapes differ. The audit format does not. It sends each event to the verified log-ingest route before the adapter runs; success and failure receive a second event with the same operation ID. The write uses a fresh idempotency key, checks real error bodies, and backs off on HTTP 429. No retry can duplicate an accepted event.

import { randomUUID } from "node:crypto";

type MxWrite = {
  zoneId: string;
  domain: string;
  recordId: string;
  name: string;
  priority: number;
  value: string;
};

type AuditEvent = {
  timestamp: string;
  operationId: string;
  actor: string;
  zoneId: string;
  recordId: string;
  action: "dns.mx.upsert";
  phase: "intent" | "result";
  outcome?: "succeeded" | "failed";
  domain: string;
  requested: Pick<MxWrite, "name" | "priority" | "value">;
  error?: string;
};

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");

const wait = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function emit(event: AuditEvent): Promise<void> {
  const idempotencyKey = `dns-audit-${event.operationId}-${event.phase}`;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/logs/ingest`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(event),
    });

    if (response.ok) return;
    const body = await response.text();

    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Audit ingest failed (${response.status}): ${body}`);
    }

    const retryAfter = response.headers.get("retry-after");
    const delay = retryAfter ? Number(retryAfter) * 1_000 : 250 * 2 ** attempt;
    await wait(Number.isFinite(delay) ? delay : 250 * 2 ** attempt);
  }
}

async function auditedMxUpsert(
  actor: string,
  write: MxWrite,
  applyRecord: (input: MxWrite) => Promise<void>,
): Promise<string> {
  if (!actor || !write.zoneId || !write.recordId) {
    throw new Error("actor, zoneId, and recordId are required");
  }

  const operationId = randomUUID();
  const base = {
    timestamp: new Date().toISOString(),
    operationId,
    actor,
    zoneId: write.zoneId,
    recordId: write.recordId,
    action: "dns.mx.upsert" as const,
    domain: write.domain,
    requested: { name: write.name, priority: write.priority, value: write.value },
  };

  await emit({ ...base, phase: "intent" });

  try {
    await applyRecord(write);
    await emit({
      ...base,
      timestamp: new Date().toISOString(),
      phase: "result",
      outcome: "succeeded",
    });
    return operationId;
  } catch (cause) {
    const error = cause instanceof Error ? cause.message : "Unknown DNS error";
    await emit({
      ...base,
      timestamp: new Date().toISOString(),
      phase: "result",
      outcome: "failed",
      error,
    });
    throw cause;
  }
}

const example: MxWrite = {
  zoneId: process.env.ZONE_ID ?? "zone-property-1042",
  domain: process.env.MAIL_DOMAIN ?? "mail.example.com",
  recordId: process.env.RECORD_ID ?? "mx-primary",
  name: process.env.MX_NAME ?? "@",
  priority: Number(process.env.MX_PRIORITY ?? "10"),
  value: process.env.MX_VALUE ?? "mx.example.net",
};

await auditedMxUpsert(
  process.env.ACTOR_ID ?? "user-4821",
  example,
  async (input) => {
    // Replace this adapter with the selected provider's documented record write.
    process.stdout.write(`Applied MX record ${input.recordId}\n`);
  },
);
Enter fullscreen mode Exit fullscreen mode

The ordering is intentional.

Picture the concrete failure path. A leasing administrator approves new mail routing for property 1042, and the application resolves that property to zone-property-1042. The provider then rejects the MX write. A success-only logger leaves no event, so a later investigator sees unchanged DNS and cannot distinguish a rejected attempt from no attempt at all. Here, the intent is already searchable under the actor, zone ID, record ID, and operation ID. The failure event adds the result without overwriting the original evidence. This before/after distinction is the control.

Do not put credentials into the event. Do not serialize whole request headers. The actor should be a stable application identity, not a display name that can change. The record identity should distinguish this MX record from another record in the same zone.

Can an investigator actually find the change later?

Search is the point. An audit log that cannot be queried is an archive, not a control. During a mail-delivery investigation, an operator should be able to start with the inventory's zone ID, narrow by actor or operation ID, and see intent next to result. A file proves the event shape locally; the production destination needs indexed fields or an equivalent query mechanism.

Do not invent hosted search filters.

The platform's verified search route declares no filter parameters, so the acceptance criterion should be expressed without pretending otherwise. The following TypeScript helper filters an exported NDJSON stream by ZONE_ID, and optionally ACTOR_ID. The same field contract should drive queries in whichever searchable destination receives the production events.

import { readFile } from "node:fs/promises";

type StoredEvent = {
  timestamp: string;
  operationId: string;
  actor: string;
  zoneId: string;
  recordId: string;
  phase: "intent" | "result";
  outcome?: "succeeded" | "failed";
};

const path = process.env.AUDIT_EXPORT_PATH ?? "./dns-audit-export.ndjson";
const zoneId = process.env.ZONE_ID;
const actor = process.env.ACTOR_ID;

if (!zoneId) throw new Error("ZONE_ID is required");

const events = (await readFile(path, "utf8"))
  .split("\n")
  .filter(Boolean)
  .map((line) => JSON.parse(line) as StoredEvent)
  .filter((event) => event.zoneId === zoneId)
  .filter((event) => !actor || event.actor === actor)
  .sort((a, b) => a.timestamp.localeCompare(b.timestamp));

process.stdout.write(`${JSON.stringify(events, null, 2)}\n`);
Enter fullscreen mode Exit fullscreen mode

Good search can be boring.

The operational test is whether an on-call engineer can answer, "Who attempted to change the MX record for this inventory zone, and what happened?" without opening three consoles or matching a domain substring.

For compliance workflows, retention, access control, and tamper resistance still need explicit policy. The event schema alone does not establish them. DMARC is adjacent rather than interchangeable: RFC 7489 covers domain-based message authentication, policy, and reporting, while this trail covers the administrative act of changing DNS.

Pick this when the boundary is clear

Choose customer-owned DNS when the customer's authority should remain intact and the platform can operate through approved access. Cloudflare DNS, Route 53, and Google Cloud DNS each belong on the shortlist when the customer already uses that ecosystem. Compare current official record-management documentation against your access-control and automation requirements. Do not migrate on the strength of a generic feature checklist.

Choose platform-owned DNS when the service contract truly includes domain lifecycle operations and the team can own the control burden. The gain is a cleaner relationship between zones and internal inventory. The cost is concentrated responsibility, including review, alerting, and evidence retention.

Choose a unified API layer when reducing backend integration count is itself valuable. Breadth behind one consistent surface can make a new capability one more endpoint instead of another SDK, key, and integration model. Keep the pre-write event in the application either way. Provider activity is supporting evidence, not the sole audit trail.

Limits to keep visible

This pattern records application intent and the observed result of the call. It does not prove that DNS resolvers worldwide observed the new MX answer, that mail flowed, or that a later actor did not change the record through another path. Those are separate checks. Provider-side access, emergency console edits, and delegated automation must feed the same searchable control or be reconciled against it.

Also, a successful API response is not a mail cutover test. Validate the provider's required MX values, DNS resolution, and mail authentication policy independently. Keep the audit event focused on who requested which record mutation in which zone.

Crisp fields beat a giant payload.

Further reading

Top comments (0)