Short answer: record every tenant DNS change as an immutable intent event containing the actor, tenant, zone ID, idempotency key, requested mutation, and timestamp; publish from that event, record the observed outcome separately, and reconcile the desired record set against authoritative DNS so later search can distinguish who asked for a change from what was actually published.
For a B2B SaaS platform that automatically gives every tenant a subdomain, the expensive part of an audit trail is rarely writing one more row. The bill is dominated by how many bytes remain in searchable storage, how many high-cardinality fields are indexed, and how much data each investigation scans. Model it before choosing a database: if E is events per day, B is average stored bytes per event, and D is searchable days, hot storage is E * B * D, before replicas and indexes. Measure E and B from production-shaped samples; don't borrow somebody else's benchmark.
The useful optimization is therefore structural, not cosmetic: index a narrow envelope such as tenant_id, zone_id, actor_id, event_id, change_id, occurred_at, and status, while keeping bulky before-and-after record documents in cheaper immutable storage addressed by a digest. That preserves fast questions such as "who changed zone Z during this deployment window?" without forcing every query engine to index duplicated DNS payloads. The catch is that a deep investigation now needs a second read, and an expired payload cannot be reconstructed from its digest.
How should DNS changes log actor and zone ID for later audit search?
Treat the log entry as evidence about a command, not as an application debug message. A sentence such as updated domain is useless six months later because it omits identity, scope, causality, and the exact proposed state. The minimum envelope needs a globally unique event ID, a stable change ID used for idempotency, the authenticated actor ID and actor type, the tenant ID, the provider-independent zone ID, the fully qualified owner name, the record type, an operation, a request timestamp, and a correlation ID that joins the change to an approval or deployment. Actor display names and email addresses can change; store the stable principal identifier, then resolve mutable presentation data at query time when policy permits.
Keep actor and subject separate. In an automated tenant onboarding flow, the actor may be a workload identity while the subject is tenant-2741.example.test. If an administrator approved the request and a worker executed it, retain both the approving principal and the executing principal rather than flattening them into one user field. This distinction matters whenever an auditor asks whether a human authorized a machine action.
A practical event can look like this in Go; the record body is deliberately provider-neutral, and the JSON tags make the same contract usable by a Node.js producer or consumer without binding the evidence model to either runtime.
package audit
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"time"
)
type DNSMutation struct {
Owner string `json:"owner"`
Type string `json:"type"`
TTL uint32 `json:"ttl"`
Values []string `json:"values"`
}
type ChangeIntent struct {
EventID string `json:"event_id"`
ChangeID string `json:"change_id"`
TenantID string `json:"tenant_id"`
ZoneID string `json:"zone_id"`
ActorID string `json:"actor_id"`
ActorType string `json:"actor_type"`
ApproverID string `json:"approver_id,omitempty"`
CorrelationID string `json:"correlation_id"`
Operation string `json:"operation"`
Desired DNSMutation `json:"desired"`
RequestedAt time.Time `json:"requested_at"`
PayloadSHA256 string `json:"payload_sha256"`
}
func AppendIntent(ctx context.Context, db *sql.DB, e ChangeIntent) error {
if e.EventID == "" || e.ChangeID == "" || e.ActorID == "" || e.ZoneID == "" {
return errors.New("missing audit identity or scope")
}
payload, err := json.Marshal(e.Desired)
if err != nil {
return err
}
digest := sha256.Sum256(payload)
e.PayloadSHA256 = hex.EncodeToString(digest[:])
envelope, err := json.Marshal(e)
if err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// The unique change_id makes a retry return the original accepted intent.
_, err = tx.ExecContext(ctx, `
INSERT INTO dns_change_intents
(event_id, change_id, tenant_id, zone_id, actor_id, requested_at, envelope)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (change_id) DO NOTHING`,
e.EventID, e.ChangeID, e.TenantID, e.ZoneID, e.ActorID, e.RequestedAt, envelope)
if err != nil {
return err
}
// An outbox worker publishes only committed intents.
_, err = tx.ExecContext(ctx, `
INSERT INTO dns_publish_outbox (change_id, available_at)
VALUES (?, ?) ON CONFLICT (change_id) DO NOTHING`,
e.ChangeID, e.RequestedAt)
if err != nil {
return err
}
return tx.Commit()
}
The SQL placeholder dialect and conflict syntax must match the selected database; the audit contract does not depend on that choice. More important, authenticate the actor outside this function and pass identity from trusted request context. Accepting actor_id from a public JSON body turns the most valuable field into an assertion made by the person being audited.
Don't overwrite an intent row when publication finishes. Append a second event such as publish_observed, linked by change_id, with the provider request identifier if one exists, the observation time, and a digest of the normalized record set read back from DNS. Mutation is convenient. It also destroys chronology.
Make publication, retries, and evidence one state machine
A database transaction cannot atomically commit both a local audit row and a change in an external DNS control plane. Pretending otherwise creates two bad orders: publish first and a process exit can leave an unlogged record; log success first and a rejected publication can leave false evidence. The safer model records durable intent and an outbox item in one local transaction, then lets an idempotent worker publish and append observations. This is an exactly-once mindset implemented with at-least-once delivery: retries are expected, while change_id and normalized desired state prevent duplicate logical mutations.
Retries happen.
Use explicit states, but never rewrite history to move between them. requested, dispatched, observed, reconciled, and superseded can be separate append-only facts; a projection computes the current state for the operator UI. A retry with the same change_id must reuse the original intent. A materially different desired record set needs a new change ID that refers to the superseded one. Otherwise a reused key can silently make two different commands look like one.
This design does not guarantee that recursive resolvers immediately return the desired answer. DNS caching, TTLs, delegation, and negative caching are outside the local transaction boundary. It guarantees something narrower and defensible: the system can prove what it intended, who authorized it, what the publisher attempted, and what later observation found.
DMARC provides a useful warning about confusing configuration with effective behavior. RFC 7489 publishes policy in DNS and defines reporting that communicates observed authentication results; the published policy record and the reports answer different questions. Tenant subdomain automation needs the same separation between desired control-plane state and independently observed state, even when the records are unrelated to email authentication.
Reconcile desired tenant subdomains with published records
Searchable history is incomplete unless a reconciler checks the world. For each managed zone, build the desired record set from the latest non-superseded intents, obtain an authoritative or control-plane view through a read-only interface, normalize both sides, and compare them. Normalization must be type-aware: canonicalize owner names consistently, compare unordered value sets as sets, and preserve fields whose semantics affect resolution. Hash only after normalization, since hashes of differently ordered but equivalent JSON arrays manufacture drift.
When the sets differ, append a drift event with zone_id, tenant ID, owner name, expected digest, observed digest, observation time, and a reason category such as missing, unexpected, or value mismatch. Do not silently repair first. An immediate repair may be the correct operational policy, but the evidence event must precede it so an investigator can distinguish detected drift from a routine requested change. Repair should use its own change ID, actor type reconciler, and a causal link to the drift event.
Drift is evidence.
Short version: observe, record, then repair.
Schedule frequency is a compliance and recovery objective, not a universal constant. A five-minute interval might be appropriate for one control environment and wasteful or inadequate for another; I'm not sure what interval fits a system without its tolerated exposure window, authoritative read limits, and tenant count. Resolve that uncertainty by defining the maximum undetected drift period, measuring a full scan, and adding partitioned checkpoints so a worker restart does not reset progress. Random sampling can supplement a full sweep, but it cannot prove that every managed name was checked.
Search projections should be disposable. Build a narrow index keyed by time plus common filters, retain the immutable event stream as the source of truth, and be prepared to rebuild the projection when mappings or access policy change. For incident response, queries usually start with a tenant, zone, actor, change ID, or time window; indexing raw record values by default expands sensitive search surface and often adds little value. Exact access rules depend on the data classification and the applicable compliance regime. Logs are evidence, and evidence still needs authorization, encryption, retention limits, and access logging.
Choose retention by investigation value, not habit
Separate three retention clocks: the compact searchable envelope, the full mutation payload, and the derived search projection. Keep the envelope for the period established by legal, contractual, and internal control requirements. Keep full before-and-after payloads only while they materially improve rollback or investigation, especially if record values can contain verification tokens or other sensitive data. Rebuildable projections can have a shorter life because they are operational accelerators, not primary evidence.
The deliberate saving comes from stopping indefinite retention of duplicate payloads in every index and replica. Keep one immutable payload object when policy requires it, bind it to the event with a SHA-256 digest, and index the small envelope. This reduces the dominant retained-and-indexed byte term without making price the architecture. It also imposes a real limitation: after the payload retention window expires, the digest can verify a payload presented from another authorized archive, but it cannot reveal the old values or support a self-contained rollback.
This pattern is not suitable when policy requires every historical value to remain immediately searchable for the entire evidence period. In that case, retain and index the payload under field-level access controls, accept the storage and indexing cost, and test deletion holds explicitly. Conversely, a database-only log is the wrong choice when administrators who can mutate DNS can also update or delete its audit rows; use an append-only destination with separately governed write and retention permissions. No storage label substitutes for testing those controls.
Before release, test duplicate delivery, worker termination between publish and observation, events arriving out of order, concurrent changes to one owner name, actor deactivation, payload expiry, projection rebuilds, and drift repair loops. The acceptance test is an audit query, not a green mutation response: given a tenant, zone ID, actor, and time range, can an authorized reviewer retrieve the immutable intent, follow its causal chain, see the independently observed state, and explain any difference?
That's the standard.
References
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
Top comments (0)