DEV Community

TheodorHawkins9251
TheodorHawkins9251

Posted on

Property Cutover Ledger: Reconciling Customer DNS and Signed Asset URLs

Short answer: activate a property-management asset hostname only when observed DNS matches declared intent, and keep signed URL authorization independent so a DNS rollback never becomes an access-control rollback.

That decision creates two clocks. DNS has an intent state and a published state; asset authorization has a policy state and a request-time decision. A cutover is ready only when the control plane observes the intended record, while every asset request still has to satisfy the signature policy. Don't let a dashboard's successful write stand in for either observation.

The concrete case is a property manager moving media.oak-court.example to a new asset delivery path while retaining the old path for rollback. The dangerous moment isn't the record edit by itself. It's the interval in which the team believes the new target is live, some resolvers still expose another answer, and freshly issued URLs grant access under assumptions tied to the wrong hostname or policy generation.

This is an architecture decision record for that interval.

Decision, invariants, and failure boundaries

Store desired DNS, observed DNS, and authorization policy as separate records. Promotion from prepared to active requires an observation that satisfies the declared hostname-to-target relationship; rollback writes a new desired state and stays visibly rolling_back until observation agrees. The system should never infer convergence merely because its DNS change request was accepted.

The first invariant is plain: a customer hostname may issue active links only for the tenant that proved control of that hostname. The second is stricter: the resource identifier, tenant, hostname, expiration, and policy generation used by the verifier must correspond to the values authorized by the control plane. The third concerns recovery: the previous delivery target and signing-policy generation remain explicit rollback inputs rather than being reconstructed from logs after trouble starts.

DNS and authorization fail differently. Name resolution can disagree with intent. A validly signed request can name the wrong tenant or host. An expired URL can reach the expected host and still be denied. A rollback can be requested but not yet observed. Treating all four cases as “CDN propagation” destroys the evidence needed to decide what to do next.

For an illustrative property record, the ledger might contain hostname=media.oak-court.example, desired_target=edge-new.example.net, previous_target=edge-old.example.net, policy_generation=12, and phase=prepared. Those values are example application data, not universal DNS settings. A team might choose a 300-second observation window for its own rollout, but that number is an operational choice to test, not a promise about when every reader will see the same answer.

Walk that example as a rehearsal before touching the customer record. At 09:00, the deployment writes generation 12 as desired but leaves generation 11 able to verify already issued links; the hostname remains prepared, so no new generation-12 link can escape. The observer then reports edge-old.example.net, which is a correct description of the current world and a mismatch with the future one, not an error to suppress. After the customer changes the record, one observation reports edge-new.example.net; the controller records that evidence but waits for the team's chosen confirmation rule before promotion. A synthetic request built for media.oak-court.example must pass against generation 12, while the same envelope presented through media.another-property.example must produce host_mismatch. Now rehearse the reverse path: set rolling_back, stop issuing generation-12 links, declare edge-old.example.net as the recovery target, and keep the phase unchanged while observation still shows the new target. Only an observation of the recovery target moves the record back to prepared. This dry run exposes the decisions people otherwise make under pressure: whether generation 11 remains acceptable, who may promote it, and how the team distinguishes routing recovery from permission to create new access grants.

Keep the boundary sharp.

DNS also carries policies unrelated to asset delivery. RFC 7489 defines DMARC for message authentication policy and reporting at a domain; it does not define authorization for retrieving an image or lease document. A customer asking for a DNS record should therefore not lead an implementation to treat every domain-level record as evidence for asset access.

How should customer domain DNS records and signed URLs control asset access?

Use DNS to establish and route the hostname, then use signed URLs to authorize a particular request. Those roles meet at the hostname binding, but they are not interchangeable. A correct DNS answer doesn't decide whether a caller may fetch leases/2026/renewal.pdf, and a valid signature should not silently authorize the same path through an unrelated customer hostname.

The enrollment flow starts with an explicit claim: tenant oak-court requests media.oak-court.example. The control plane records the expected DNS relationship and waits for an independent observation. Only after the observation matches does the hostname become eligible for activation. If observation later differs, the control plane marks drift and stops minting new links for the affected hostname; handling already issued links remains a separate, deliberate policy decision.

For each new URL, bind the authorization envelope to the tenant, asset key, hostname, expiration, and a policy generation. The verifier reconstructs that same envelope from the incoming request and rejects any mismatch. The generation gives operators a bounded way to invalidate one policy lineage without pretending that a DNS edit revokes cryptographic authorization. The catch is that stricter binding reduces accidental cross-host reuse but makes hostname migrations require an intentional overlap or reissue plan.

Not every asset needs a signed URL. Public listing photos that are intentionally cacheable can use public object identifiers behind a customer hostname, provided publication is an explicit data classification. Signed URLs fit private maintenance photos, applicant documents, and lease files where request-level expiration and scope are part of the access decision. Mixing those classes under one implicit default is easy at first and painful during an audit.

The request path should emit a compact decision reason such as host_mismatch, expired, unknown_generation, or asset_scope_mismatch. These are application outcomes, not anecdotes about a particular service. Pair them with the tenant, hostname, policy generation, and a correlation identifier; omit the signature itself. I'm not sure one retention period suits every property operator, because legal and privacy requirements differ, so the owning team has to set retention after reviewing those requirements.

The critical path belongs in one state machine

The useful code is the guard around activation and rollback, not a vendor-specific DNS call. The following Python sketch assumes that DNS observation and URL signing sit behind reviewed interfaces. It deliberately does not prescribe record syntax, resolver behavior, or a provider endpoint.

from dataclasses import dataclass
from enum import Enum
from typing import Protocol


class Phase(str, Enum):
    PREPARED = "prepared"
    ACTIVE = "active"
    DRIFTED = "drifted"
    ROLLING_BACK = "rolling_back"


@dataclass(frozen=True)
class DomainIntent:
    tenant: str
    hostname: str
    desired_target: str
    previous_target: str
    policy_generation: int
    control_proved: bool
    phase: Phase


class Observer(Protocol):
    def target_for(self, hostname: str) -> str | None: ...


class Signer(Protocol):
    def issue(
        self,
        *,
        tenant: str,
        hostname: str,
        asset_key: str,
        policy_generation: int,
    ) -> str: ...


def reconcile(intent: DomainIntent, observer: Observer) -> Phase:
    observed = observer.target_for(intent.hostname)

    if intent.phase == Phase.ROLLING_BACK:
        return (
            Phase.PREPARED
            if observed == intent.previous_target
            else Phase.ROLLING_BACK
        )

    if not intent.control_proved:
        return Phase.PREPARED
    if observed != intent.desired_target:
        return Phase.DRIFTED
    return Phase.ACTIVE


def issue_asset_url(
    intent: DomainIntent,
    observer: Observer,
    signer: Signer,
    asset_key: str,
) -> str:
    phase = reconcile(intent, observer)
    if phase != Phase.ACTIVE:
        raise PermissionError(f"hostname is not active: {phase.value}")

    return signer.issue(
        tenant=intent.tenant,
        hostname=intent.hostname,
        asset_key=asset_key,
        policy_generation=intent.policy_generation,
    )
Enter fullscreen mode Exit fullscreen mode

There is an intentionally awkward detail here: after rollback observation matches the previous target, the code returns prepared, not active. Recovery has restored routing, but it has not proved that the previous authorization generation should mint new links. An operator or policy controller must make that promotion explicitly. That small inconvenience protects the distinction between “traffic goes somewhere known” and “new private access grants are permitted.”

Test this state machine as a matrix rather than as one happy-path integration test. Cover an unproved hostname with a matching target, a proved hostname with a mismatching target, an active generation with a host mismatch, an expired authorization, and rollback before and after the previous target is observed. Then run a synthetic fetch for one public asset and one private asset from outside the control-plane network. The synthetic result is evidence about the data path; it should not mutate desired state.

Observability should expose the transition, not only the endpoint. Record when intent changed, when each observation was made, what normalized answer was evaluated, which policy generation issued a URL, and which decision reason denied a request. Alert on the age of unresolved drift and on links minted while the hostname is outside active; raw request volume alone can't tell an operator whether a property cutover is safe.

Options and their honest trade-offs

The primary comparison axis is how each option handles drift between declared intent and the records clients can observe. Cost belongs in capacity planning, but it isn't the architectural discriminator here; the failure boundary is.

Option Drift handling Rollback property Operational cost Suitable when
Pre-provision, observe, then activate Makes disagreement a named state before serving Previous target stays recorded; recovery waits for observation More control-plane state and tests Private or mixed assets where hostname and policy must agree
Edit and immediately mark active Hides the interval between write acceptance and observation Fast to request, hard to prove complete Less orchestration, more incident ambiguity Low-risk public assets with a tested manual recovery procedure
Serve through an operator-controlled hostname Avoids a customer-record cutover on each change Operator can change its own delivery mapping Customer branding may be lost Internal tools or properties that don't require a branded asset host
Application proxy for every asset Moves routing and authorization into the application path Application release controls recovery Adds application traffic and another capacity boundary Small, sensitive workloads needing centralized request decisions

The recommended pre-provision-and-observe model is not suitable when the organization cannot operate a durable control-plane ledger or test resolver observations. In that case, stick with an operator-controlled hostname for public assets, or use an application proxy for a small private workload if the application team already owns its availability and authorization boundaries. Those options trade branded delivery or direct asset serving for a failure mode the team can actually diagnose.

No row eliminates coordination. The ledger model demands idempotent transitions, ownership review, credential separation, and a repair process for records changed outside the deployment workflow. It also demands a product decision about already issued URLs during drift. Denying all of them is conservative but disruptive; allowing them until expiration preserves availability but accepts a longer authorization window. The right choice depends on asset sensitivity, and the decision must be written before cutover day.

Rejected shortcut and the narrow case where it works

Reject the shortcut that marks a customer domain active immediately after sending a DNS write. It collapses request acceptance, public observation, and application authorization into one green check, so the system cannot distinguish delayed publication from an incorrect target or an authorization-policy mismatch. It also weakens rollback: writing the old value is an action, while observing the old value is evidence.

There is a valid narrow use case. A small catalog of deliberately public building photos, served from an operator-owned hostname, may not need customer-domain enrollment or per-request signatures at all. A simple deployment with documented manual recovery can be the more honest system there. Adding a tenant-domain state machine to data that has no private access boundary creates operational machinery without reducing meaningful risk.

For private or mixed property assets, keep the two clocks visible: published DNS must reconcile with intent, and every request must reconcile with authorization policy. Activation needs both. Rollback does too.

References

Top comments (0)