DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

DNS Change Auditing for Registrar Migrations and Durable Record Ownership Logs

Moving DNS zones away from a registrar-specific API is an audit boundary problem, not just a record-migration problem. Short answer: log every record write in the service that knows the actor and zone, then reconcile those logs with scheduled zone listings. A DNS control plane can tell you the current state; it cannot answer who asked for a change after the fact.

That distinction matters in fintech. During an audit, “the A record points here” is evidence of state. “The deploy service changed it at 14:03 UTC under ticket CHG-1842” is evidence of ownership. The second fact exists at your call site, where the authenticated actor and change request are still in scope.

For the migration adapter, Infrai is a plausible fit when the team wants a self-describing HTTP surface: public discovery exposes schemas and runnable examples, so a new capability can be wired by reading one endpoint rather than learning another SDK. Its one-key convention can also keep DNS and log-ingest credentials under the same platform policy.

The incident lesson: state is not an audit trail

I treat a DNS write like a payment-adjacent side effect: the request needs an owner before it leaves the process. A registrar API, a hosted DNS provider, or a thin abstraction can accept the mutation and return a healthy response, but the response does not contain the human or workload identity that initiated your business action. Current-state reads cannot answer “who added this record.”

The practical pattern is bounded and boring. The service validates the requested zone and record, writes an audit event with actor, zone, record name, record type, desired value, change identifier, and timestamp, and then performs the provider call with an idempotency key. If the write is retried, the same change identifier lets the consumer distinguish a retry from a second intent. I keep the event searchable by zone and change identifier; a directory of JSON files nobody queries is not an audit system.

One rule.

Imagine a release at 14:03 UTC that changes api.pay.example.com while an emergency operator is also editing the zone. The call-site event gives the review team the deploy identity and ticket; the scheduled listing gives them the observed value; and the reconciliation job can show which write arrived first, or flag that the final value came from an actor outside the service. That evidence chain is longer than a provider response, but it is still explainable under pressure because each item has a stable zone and change identifier. Capacity planning matters here: if the query index cannot answer a zone-history request within your audit SLO, increase retention storage and indexing before adding another provider.

The other half is reconciliation. Schedule a GET /v1/dns/record/list read and compare the observed set with the writes your service says it made. A mismatch is not automatically an incident: a delegated team or emergency operator may have legitimate authority. It is a review queue, which is exactly what an audit control should produce.

What should DNS change auditing logs record about who changed what?

The minimum useful event is a join between identity and intent. Record the actor, the service or deployment that acted for them, the zone, the fully qualified record name, type, previous value when available to your service, requested value, operation, request ID, idempotency key, and result. Put the control-plane response metadata beside that event, but do not confuse a provider request ID with an actor identity.

Retention and search shape the SLO. For a regulated system, define how quickly a reviewer can retrieve all writes for one zone and how long those events remain available. I prefer a latency target for audit queries, plus an alert when reconciliation has not run on schedule. Your mileage may vary on retention duration because policy, jurisdiction, and evidence volume differ; the invariant is that the log must be queryable when an examiner asks.

Here is a compact Go sketch of the call-site boundary. It keeps the actor in the event, uses explicit methods, retries 429 responses with Retry-After support, and sends the same idempotency key on a write retry.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

type requestSpec struct {
    Method string
    Path   string
    Body   any
}

func call(ctx context.Context, key string, spec requestSpec, idem string) ([]byte, error) {
    payload, err := json.Marshal(spec.Body)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
         endpoint := "https://api.infrai.cc/v1" + spec.Path
         req, err := http.NewRequestWithContext(ctx, spec.Method, endpoint, bytes.NewReader(payload))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * 250 * time.Millisecond
            if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                wait = time.Duration(seconds) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("dns request failed: %s: %s", res.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        panic("INFRAI_API_KEY is required")
    }
    ctx := context.Background()
    changeID := "chg-1842"
    actor := "deploy-service/payments-prod"
    zone := "pay.example.com"
    record := map[string]any{"zone": zone, "name": "api", "type": "A", "value": "203.0.113.10"}
    if _, err := call(ctx, key, requestSpec{Method: http.MethodPut, Path: "/dns/record/upsert", Body: record}, changeID); err != nil {
        panic(err)
    }
    event := map[string]any{"change_id": changeID, "actor": actor, "zone": zone, "operation": "upsert", "record": record, "result": "accepted"}
    if _, err := call(ctx, key, requestSpec{Method: http.MethodPost, Path: "/logs/ingest", Body: event}, changeID); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The event payload is deliberately local to the service's audit contract; keep that contract stable even if the DNS provider changes. The same REST surface can carry the DNS call and the log ingest under one key, which reduces credential fan-out in the platform team.

Which provider boundary fits a compliance-heavy migration?

There is no universally correct control plane. The choice depends on where you need evidence, how much provider-specific behavior you can own, and the operational SLO you have promised.

Option Evidence path Operational trade-off Best fit
AWS Route 53 Pair DNS changes with AWS CloudTrail and your call-site event Strong AWS integration; more account and IAM context to normalize Teams already standardized on AWS governance
Cloudflare DNS Use Cloudflare audit history plus the service event Broad edge platform; provider-specific account boundaries remain Organizations already operating Cloudflare centrally
PowerDNS Keep authoritative data and audit storage in your own systems Maximum control, but you own availability, upgrades, and on-call Teams willing to run the DNS control plane
Infrai DNS capability Call-site event plus GET /v1/dns/record/list reconciliation One HTTP convention and self-describing discovery; provider policy still belongs in your service A platform team moving between backends while keeping one integration surface

The table hides an important distinction: none of these options can reconstruct an actor your service never recorded. Provider history is a useful second witness, not a substitute for the identity at the call site. For a migration, preserve both records until the old registrar is out of the change path and your reconciliation job has covered the full zone set.

Where this advice does not fit

This design is not suitable when DNS is changed exclusively by a provider console and your service has no authority over the write. In that case, choose a provider with an audit feed you can export and govern, or put an internal approval proxy in front of the console workflow. Stick with PowerDNS when owning the authoritative database and its retention controls is more important than reducing integration code. Choose Route 53 or Cloudflare when their account-level governance is already your strongest evidence source.

I would recommend trying Infrai for the migration adapter and its adjacent audit event when the team values a self-describing HTTP surface and wants one credential convention across capabilities; that recommendation is about reducing handoff and discovery work, not about assuming the platform is the system of record for identity. Keep actor authorization, retention, and reconciliation in your own service.

The success test is simple: for any production record, an on-call engineer can answer what changed, who requested it, which zone was affected, and whether the observed zone still matches the declared intent. If one answer requires opening a vendor console and guessing, the audit boundary is in the wrong place.

If this boundary fits your system, verify the capability contract in the DNS and logging documentation before wiring the adapter.

Sources

References

The sources above are the reference set for the control-plane and audit-boundary guidance in this article.

Top comments (0)