Skip to content

Part 7 of 7 · DNS change auditor series ~7 min read

Engineering reference: the dns change auditor architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the resolver discipline, and the read-only posture.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The dns change auditor drawn with AWS service namesThree boxes across the top outside the AWS account. Public resolvers, two of them plus a third for critical records. The Name list, read through the Google Sheets API read-only. And SNS with SES, carrying alarms and the daily digest. Inside the account, three groups. EventBridge carrying an hourly snapshot and a daily digest schedule. Three Lambda functions named snapshot, diff and notify. And two DynamoDB tables named current and changes. A note gives the region as us-east-1, one account, and states that no credential in it can create, change or delete a DNS record.AWS ACCOUNTPublic resolverstwo, plus one for criticalName listSheets API, read-onlySNS + SESalarms and the digestEventBridgehourly snapshot,daily digestLambda x3snapshot, diff,notifyDynamoDB x2current, changesingroundsoutus-east-1. One account. No credential in it can create, change or delete a DNS record.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • App integration
  • Networking
  • Management

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
da-snapshotEventBridge hourlyResolves every name and type from two resolvers; hashes the result120s / 512 MB
da-diffSQS snapshot queueCompares with current; classifies and describes each change30s / 512 MB
da-notifySQS change queue + EventBridge dailyAlarms on critical, digests the rest, runs the escalation20s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
da-snapshot-roledynamodb:GetItem, secretsmanager:GetSecretValueThe current table, read; the Sheets credential only
da-diff-roledynamodb:PutItem, bedrock:InvokeModelCurrent and changes; one model arn
da-notify-rolesns:Publish, ses:SendEmailThe operations topic; one verified identity

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: current

PK   name              S   example.com
SK   rrtype            S   MX
     values            L   sorted, so ordering is not a diff
     digest            S   sha256 of the sorted values
     critical          BOOL true
     resolvers_agree   BOOL true
     last_seen         S   2026-07-27T14:00:00Z
     spf_lookups       N   only on the apex TXT: 7

Values are SORTED before hashing. Two MX records returned in a different
order by a resolver is not a change, and un-sorted comparison would report
one every few hours.

Table: changes

PK   name_rrtype       S   example.com#MX
SK   seen_at           S   2026-07-27T14:00:00Z
     kind              S   added | modified | deleted | unresolvable | reappeared
     old               L   the previous values
     new               L   the current values
     consequence       S   from the table, or from the model for SPF/DMARC
     critical          BOOL true
     confirmed_by      S   who said it was them
     confirmed_at      S   when
     escalated_to      L   [who, when]
     ttl               N   epoch, +3 years

Inbound and outbound

  • Outbound DNS only, plus the signed confirmation links. There is no inbound path of any kind.
  • Two resolvers for ordinary records and three for critical ones, so a stale cache cannot manufacture a change or delay a real one.
  • DNS over HTTPS to public resolvers, rather than UDP, so the whole thing works from a Lambda with no VPC and no NAT gateway. That last detail is worth more than it sounds: a NAT gateway would cost thirty times the rest of this system.
  • Nothing in the account has a Route 53 write permission, and a read permission only where a provider API is used for enumeration.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used only to describe the practical difference between two SPF or DMARC strings.
  • Every other record type uses a fixed sentence from the consequence table, which is a sheet somebody can improve without a deploy.
  • Called a handful of times a year. SPF and DMARC records change rarely, which is exactly why a change to one matters.
  • Output is a JSON schema with one sentence and a nullable named sender. A null produces the diff with no interpretation rather than a guess.
  • It is never asked whether a change is bad. That is the question a person answers by recognising their own work or not.

Things worth knowing before you build it

  • Sort record values before hashing. Resolvers return multi-value records in varying order and an unsorted comparison reports a change every few hours.
  • Use DNS over HTTPS from Lambda. Doing UDP DNS properly means a VPC and a NAT gateway, which would cost thirty times the entire rest of this system.
  • Require two resolvers to agree. A single resolver mid-propagation reports a change and then reports it back, which is how a monitor gets muted.
  • Count SPF lookups. It is the one failure with no author and no event, and no diff will ever catch it.
  • Keep the critical list short. Putting every A record on it means an alert every time a developer does their job.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts