DEV Community

RemielBarrett8283
RemielBarrett8283

Posted on

Tenant DNS Instructions and Verification PDFs Explained (One Source Set, Rendered in Go)

Every tenant in a healthtech platform eventually wants its own subdomain, and the record that makes that subdomain resolve is almost never created by someone who has a login to your product. It gets created by a clinic's IT contractor, on a Tuesday, from a forwarded email. So generate the customer-facing DNS instructions and the verification check from the same intended record set: one declarative source, two renderings, and a digest that binds them to each other.

In short: derive both artifacts from the intended records and let the digest prove they match.

The decision axis underneath this is propagation delay against cutover speed, and it quietly determines the record shape you hand the customer. Before any of that, though, look at what the onboarding bill is actually made of, because the dominant term is not the one most teams optimize.

What the tenant onboarding bill is actually made of

Per-tenant DNS is not where the money goes. One record write and a handful of resolver lookups per tenant are rounding errors against everything else on the invoice, and any design that starts by minimizing them is optimizing a rounding error. The dominant term is human handling: tenants, times documents sent per tenant, times the minutes a third-party administrator spends staring at a value that doesn't match what your verifier expects. Each mismatch buys you a support thread with somebody who has no account on your platform, a re-render, and a second propagation window before the tenant can be switched on. Ten clinics onboarding in the same month with one bad value each is a week of somebody's calendar, and none of it shows up as infrastructure spend.

The second term is retention, and it's the one that gets forgotten until an auditor asks. An instruction document is a record of what you told a covered entity to do inside its own namespace. The HIPAA administrative requirements are explicit about how long that kind of documentation lives: six years from creation or from the date it was last in effect, whichever is later (45 CFR 164.316(b)(2)(i)). Every regeneration therefore produces a retained artifact, and a workflow that re-renders the document on each support reply quietly multiplies what you are obliged to keep.

So the change that moves the dominant term is dull: stop authoring the document.

Derive it instead. When the instruction text, the verification input, and the provisioning call all fall out of one declarative record set, a disagreement between the document and the check stops being a thing you catch in review and becomes structurally impossible.

How should you generate DNS instructions and the verification PDF from one source?

The source is a small sorted set of intended records, treated as immutable. Four fields per record — name, type, value, TTL — and nothing else. Anything that isn't one of those four is presentation, and presentation belongs in a versioned template rather than in the state you reconcile against. Canonicalize the set, hash it, then render: the digest of the canonical form becomes the identity of the document, the payload of the verification token, and the key of the audit row. A mutation is not an edit; it is a new set, a new digest, and a new document.

package tenantdns

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "net"
    "sort"
    "strings"
    "time"
)

// Record is the only thing a human authors. Everything the customer reads is derived from it.
type Record struct {
    Name  string `json:"name"`
    Type  string `json:"type"`
    Value string `json:"value"`
    TTL   int    `json:"ttl"`
}

// Intent is immutable: a change to the set produces a new digest, a new document, a new check.
type Intent struct {
    TenantID string   `json:"tenant_id"`
    Template string   `json:"template_version"`
    Records  []Record `json:"records"`
}

// Digest identifies the customer document and the verification run that clears it.
func (in *Intent) Digest() (string, error) {
    sort.Slice(in.Records, func(i, j int) bool {
        a, b := in.Records[i], in.Records[j]
        if a.Name != b.Name {
            return a.Name < b.Name
        }
        if a.Type != b.Type {
            return a.Type < b.Type
        }
        return a.Value < b.Value
    })
    payload, err := json.Marshal(in)
    if err != nil {
        return "", err
    }
    sum := sha256.Sum256(payload)
    return hex.EncodeToString(sum[:]), nil
}

// Instructions renders the exact lines an operator has to type: no paraphrase, no screenshots.
func (in *Intent) Instructions(digest string) string {
    var b strings.Builder
    fmt.Fprintf(&b, "Tenant %s - document %s (template %s)\n\n", in.TenantID, digest[:12], in.Template)
    fmt.Fprintf(&b, "%-6s %-40s %-34s %s\n", "TYPE", "NAME", "VALUE", "TTL")
    for _, r := range in.Records {
        fmt.Fprintf(&b, "%-6s %-40s %-34s %ds\n", r.Type, r.Name, r.Value, r.TTL)
    }
    return b.String()
}

// Observation is what survives retention trimming: what a resolver answered, and when.
type Observation struct {
    Name    string    `json:"name"`
    Type    string    `json:"type"`
    Answer  string    `json:"answer"`
    Matches bool      `json:"matches"`
    SeenAt  time.Time `json:"seen_at"`
}

// Verify reads the same set the document was rendered from.
func (in *Intent) Verify(ctx context.Context, res *net.Resolver, now time.Time) []Observation {
    out := make([]Observation, 0, len(in.Records))
    for _, r := range in.Records {
        obs := Observation{Name: r.Name, Type: r.Type, SeenAt: now}
        switch r.Type {
        case "CNAME":
            target, err := res.LookupCNAME(ctx, r.Name)
            if err == nil {
                obs.Answer = target
                obs.Matches = strings.EqualFold(strings.TrimSuffix(target, "."), strings.TrimSuffix(r.Value, "."))
            }
        case "TXT":
            values, err := res.LookupTXT(ctx, r.Name)
            if err == nil {
                obs.Answer = strings.Join(values, " ")
                for _, v := range values {
                    if v == r.Value {
                        obs.Matches = true
                    }
                }
            }
        }
        out = append(out, obs)
    }
    return out
}
Enter fullscreen mode Exit fullscreen mode

Instructions is the plain-text spine of the PDF; the layout engine decorates it, adds the clinic's name and your support address, and changes nothing about the four fields. What the contractor receives looks like this, and the example values are the whole point of the exercise:

Tenant northlake-clinic - document 9f2c41ab77d0 (template v4)

TYPE   NAME                                     VALUE                              TTL
CNAME  portal.northlake-clinic.example.net      tenants.example.net                300s
TXT    _verify.portal.northlake-clinic.exa...   tenant-verify=9f2c41ab77d0         60s
Enter fullscreen mode Exit fullscreen mode

The verification token is the digest, and that's the part I would defend hardest in a design review. A contractor working from last month's email publishes last month's token, and the check can say so precisely — wrong revision, here is the current one — instead of reporting an unhelpful mismatch. It also gives reconciliation an idempotency key for free: a check that observes the current digest is a no-op, a check that observes an older one is a known state with a known remedy, and nothing in between requires a judgement call.

Exactness matters more than it looks from the inside. DMARC's external reporting destinations only work when the destination zone publishes an authorizing record whose name is derived from the origin domain (RFC 7489, section 7.1), so a tenant that wants aggregate reports delivered to a mailbox you host depends on a record nobody retypes correctly from a screenshot. Paraphrase is how records like that get lost, and a generated document is the cheapest defense against paraphrase.

None of this is novel one layer down. Zone-as-code tools such as dnscontrol and octodns already treat the intended set as the artifact of record, and external-dns does the same inside a cluster by reconciling records from resource annotations. What they don't render is the customer-facing half, because the reader of that half is not an engineer on your team.

Propagation delay versus cutover speed

A wildcard record under a delegated subdomain (RFC 4592) makes every future tenant immediate: nothing to create, nothing to wait for, the tenant is reachable the moment your router knows the hostname. Explicit per-tenant records cost you a window bounded by TTL, and buy you a real audit row per write.

The window is worse than the positive TTL suggests, which is the part that catches people who reason only about the happy path. If your verifier queries the name before the contractor has created the record, the resolver caches that negative answer for an interval derived from the zone's SOA record (RFC 2308) — your own eager polling is then what keeps the tenant waiting. Two cheap corrections: don't start checking until the customer says they're finished, and keep tenant records short-lived, which for us means publishing at 300 seconds and dropping to 60 the day before a planned cutover. Ask the resolver you actually verify from, though. Recursive resolvers clamp both positive and negative TTLs to local policy, and I'm not sure any two of them agree on the ceiling.

Record shape Cutover for a new tenant What it costs you
Wildcard under a delegated subdomain Immediate, no per-tenant wait No per-name proof; one name resolving says nothing about the next tenant
Explicit record in your own zone One TTL window after provisioning A record per tenant to reconcile, plus the audit row
Customer-owned domain pointed at your zone Bounded by their operator, not by you The generated document is the entire interface

The catch is that a wildcard only helps for names inside a zone you control, which is the case where the customer document matters least. For a clinic that insists on portal.its-own-domain.org, and the larger ones usually do, you are generating instructions for a stranger and the propagation clock belongs to them. Per-name TLS pushes the same direction: the ACME dns-01 challenge (RFC 8555) needs a TXT record carrying a token unique to the name being certified, and a wildcard answers every name with identical data, so anything genuinely per-tenant — a distinct validation token, a narrower CAA policy (RFC 8659) — needs a real record at a real name.

Wildcard for the default tenant subdomain, explicit records for anything a certificate or an auditor has to point at, and a generated document for every name you don't own.

What I stop keeping, and what that costs when a record is wrong

Here is where a cost-driven design gets uncomfortable, because the artifact everybody assumes we archive is the one I throw away: the rendered files. What stays is the intent JSON, the digest, the template version, and one observation row per check — name queried, what the resolver returned, whether it matched, timestamp. Rendering is deterministic, so any historical document can be reproduced from the digest plus the template version, which turns an archive of near-identical binaries into a few hundred bytes per tenant per revision.

That trade-off has teeth. Deterministic re-rendering is a promise about your own toolchain, and the promise is broken quietly: swap a font, bump the PDF library, forget to pin the template, and you can no longer produce the file the customer actually received — precisely at the moment someone is asking what you told them to do. So the template version is part of the digest input, template releases are immutable, and every template version carries a golden-file test. Skip that test and you should keep the blobs instead.

Two observations are worth keeping even though they look redundant: the raw resolver answer rather than a boolean, and the vantage point it came from. A dispute six months later is about what a specific resolver returned at a specific time, and matches: false alone cannot reconstruct that.

This design isn't suitable everywhere. If you hold provider credentials for the tenant's zone and can write the records yourself, generating a document for a human is ceremony — stick with a direct provisioning call and an internal reconciliation job. If your compliance posture needs a countersigned artifact, the re-render argument collapses, since a signature binds one specific file; keep that file, under whatever retention the contract names rather than your own policy. A single-tenant deployment with one domain needs none of this.

Tenants get switched on when their records resolve, not when somebody re-reads a document. Build the document so it can't describe anything other than the records you are going to check.

References and further reading

Top comments (0)