25 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud | 02 | execute | 2 |
|
|
true |
|
|
Purpose: SC-1 (scheduled sync with AWS as source of truth) and the drift half of SC-4
(queryable record-level history, not just current state).
Output: lib/services/route53-record-key.ts (pure helpers + tests),
lib/services/route53-sync-service.ts (+ tests), exporting getRoute53SyncService() for
plans 24-05 and 24-06 to consume.
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-PATTERNS.md @.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-01-SUMMARY.mdlib/services/route53-factory.ts: isRoute53Configured(): boolean getRoute53Client(): Route53Client resetRoute53Client(): void
lib/types/route53.ts: Route53Record { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, syncedAt, isDeleted } Route53SyncResult { syncId, syncType, status, startedAt, completedAt, duration, entities, errors } Route53HistorySource = 'pulse_crud' | 'sync_detected_drift'
Postgres (migration 102): route53_zones(id TEXT PK, name, comment, private_zone, record_count, authoritative_name_servers JSONB, raw_payload JSONB, created_at, updated_at, synced_at, is_deleted, deleted_at) route53_records(record_key TEXT PK, zone_id TEXT FK->route53_zones(id) ON DELETE CASCADE, name, type, set_identifier, ttl, resource_records JSONB, alias_target JSONB, raw_payload JSONB, created_at, updated_at, synced_at, is_deleted, deleted_at) route53_record_history(id UUID PK, zone_id, record_key, record_name, record_type, change_action CHECK IN ('create','update','delete'), before_value JSONB, after_value JSONB, source CHECK IN ('pulse_crud','sync_detected_drift'), changed_by_user_id, changed_by_email, audit_log_id, changed_at)
Pre-existing (migration 001, do not alter): sync_history(id SERIAL PK, entity_type VARCHAR(100), sync_type VARCHAR(50) CHECK IN ('full','incremental','entity-specific'), status CHECK IN ('started','in_progress','completed','failed'), started_at, completed_at, records_added, records_updated, records_deleted, error_message, triggered_by, entity_details)
lib/services/postgres-client.ts default export postgresClient:
.query(sql, params?) -> { rows: T[] }
.transaction(fn)
Export:
buildRecordKey(input: { zoneId: string; name: string; type: string; setIdentifier?: string | null }): stringproducing${zoneId}:${name}:${type}:${setIdentifier ?? ''}. This is theroute53_records.record_keyprimary key and therecordIdURL segment used by plan 24-05's routes.normalizeRecordSet(rs: ResourceRecordSet, zoneId: string): NormalizedRecordSetwhereNormalizedRecordSetis{ recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget }. Normalization rules:namelowercased with its trailing dot preserved;typeuppercased;ttlisrs.TTL ?? null;resourceRecordsis(rs.ResourceRecords ?? []).map(r => ({ value: r.Value })).sort((a,b) => a.value.localeCompare(b.value));aliasTargetisrs.AliasTarget ?? null;setIdentifierisrs.SetIdentifier ?? null. Sorting matters: AWS does not guarantee value ordering, and unsorted comparison would produce phantom drift history rows on every sync.recordSetsEqual(a: NormalizedRecordSet | null, b: NormalizedRecordSet | null): booleancomparingttl, the serializedresourceRecordsarray, and the serializedaliasTarget. Both null returns true; one null returns false.classifyDrift(prev: NormalizedRecordSet | null, next: NormalizedRecordSet | null): 'create' | 'update' | 'delete' | nullmapping toroute53_record_history.change_action, returningnullwhen there is no material difference so the sync does not write a no-op history row.toHistoryPayload(ns: NormalizedRecordSet | null): unknownreturning the JSONB shape stored inbefore_value/after_value:nullfor a null input, otherwise{ name, type, setIdentifier, ttl, resourceRecords, aliasTarget }— the whole-recordset snapshot (24-RESEARCH.md Anti-Patterns: Route 53 has no partial-value primitive, so the history unit of change is the whole recordset).
Do not implement per-value diffing anywhere.
Create lib/services/route53-record-key.test.ts covering every <behavior> case above.
Import describe/it/expect explicitly from vitest (globals: false in
vitest.config.ts).
npx vitest run lib/services/route53-record-key.test.ts && npx tsc --noEmit --pretty
<acceptance_criteria>
- npx vitest run lib/services/route53-record-key.test.ts passes with at least 10 assertions covering every <behavior> bullet
- grep -c "from 'pg'\|postgres-client\|Route53Client" lib/services/route53-record-key.ts returns 0 — the module has no runtime dependency on a DB pool or an AWS client
- classifyDrift returns null for equal recordsets (asserted in the test file), preventing no-op history rows
- Re-ordering resourceRecords does not change recordSetsEqual's result (asserted in the test file)
- npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
Pure helpers exist with full unit coverage; no phantom-drift ordering bug; type-check clean.
export class Route53SyncServicewith a privateclient: Route53Client(constructor takes an optional client, defaulting togetRoute53Client()) and a privateisSyncing = falseflag.isSyncInProgress(): booleanfullSync(triggeredBy = 'system'): Promise<Route53SyncResult>→executeSync('full', triggeredBy)incrementalSync(triggeredBy = 'system'): Promise<Route53SyncResult>→executeSync('incremental', triggeredBy)- private
executeSync(syncType, triggeredBy)that throws'A Route 53 sync operation is already in progress'whenisSyncing, sets the flag, buildssyncId = 'route53-' + Date.now(), inserts thesync_historyrow (entity_type='route53',sync_type= the literal'full'/'incremental'— the table's CHECK constraint only allowsfull/incremental/entity-specific, so do NOT writeroute53-fullthere), runs the step loop, updatessync_historyon completion, and resetsisSyncingin afinallyblock. - Step array in order:
{ name: 'zones', fn: () => this.syncZones() }then{ name: 'records', fn: () => this.syncRecords() }. Zones must run first becauseroute53_records.zone_idhas an FK toroute53_zones(id). - Wrap each step in its own try/catch so one failing step does not abort the other, pushing
{ entity, success, recordsUpserted, duration, error }intoentityResults— same shape asVeeamSyncService. - Catastrophic-failure catch block updates
sync_historytostatus='failed'witherror_message, matching the Veeam analog's lines ~152-173. Log with a[ROUTE53-SYNC]prefix. Logerror.messageonly — never the full AWS SDK error object, which can carry request headers (T-24-03).
syncZones(): paginate ListHostedZonesCommand using Marker / IsTruncated /
NextMarker. For each zone strip the /hostedzone/ prefix from Id. To populate
authoritative_name_servers (needed by plan 24-04's D-12 check), call
GetHostedZoneCommand({ Id: zoneId }) per zone and store DelegationSet?.NameServers ?? []
as JSONB. Upsert with INSERT INTO route53_zones (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ... synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL.
After the loop, soft-delete zones no longer returned by AWS:
UPDATE route53_zones SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE is_deleted = false AND id <> ALL($1)
using the collected id array. Return the upserted count.
syncRecords(): load the live zone id set with
SELECT id FROM route53_zones WHERE is_deleted = false (the FK-safety-set pattern from
veeam-sync-service.ts syncBackupServers()). For each zone, paginate
ListResourceRecordSetsCommand using StartRecordName / StartRecordType /
StartRecordIdentifier from NextRecordName / NextRecordType / NextRecordIdentifier
while IsTruncated. Normalize each recordset with normalizeRecordSet() and derive its key
with buildRecordKey(). Upsert into route53_records on ON CONFLICT (record_key) DO UPDATE,
resetting is_deleted = false, deleted_at = NULL, synced_at = NOW(), updated_at = NOW().
After each zone's pagination completes, soft-delete that zone's records no longer present:
UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE zone_id = $1 AND is_deleted = false AND record_key <> ALL($2).
Do NOT hard-delete — the history ledger references record_key.
Both fullSync and incrementalSync run the same two steps. Per D-11 the difference is
cadence, not scope: Route 53's list APIs expose no modification cursor, so an "incremental"
run is the same full read against AWS with the same diff, just scheduled more frequently.
Add a comment in executeSync stating this explicitly so a future reader does not assume a
missing incremental optimization is a bug.
Export a getRoute53SyncService(): Route53SyncService module-level singleton following
pax8-sync-service.ts's pattern (lazy let instance + accessor). Plans 24-05 and 24-06
import this.
Do NOT add any integration_settings.disabled check in this file — per D-10 the Route 53
disable toggle is display-only and must never gate sync.
npx tsc --noEmit --pretty && grep -q "getRoute53SyncService" lib/services/route53-sync-service.ts && grep -qv "integration_settings" lib/services/route53-sync-service.ts && echo PASS
<acceptance_criteria>
- lib/services/route53-sync-service.ts exports Route53SyncService and getRoute53SyncService
- npx tsc --noEmit --pretty exits 0
- grep -c 'integration_settings' lib/services/route53-sync-service.ts returns 0 (D-10 — disable never gates sync)
- grep -c "entity_type" lib/services/route53-sync-service.ts >= 1 and the inserted sync_type value is the literal 'full' or 'incremental', satisfying sync_history's CHECK constraint
- Both ListHostedZonesCommand and ListResourceRecordSetsCommand pagination loops are present: grep -c 'IsTruncated' lib/services/route53-sync-service.ts >= 2
- Soft-delete statements exist for both tables: grep -c 'is_deleted = true' lib/services/route53-sync-service.ts >= 2
- No console.error call passes a raw error object: every logging site uses error instanceof Error ? error.message : String(error) (T-24-03)
</acceptance_criteria>
Sync service mirrors zones and records with pagination, soft-delete, and sync_history bookkeeping; no disable gating; type-check clean.
Wire it into syncRecords(): before upserting a zone's recordsets, load that zone's current
mirror rows (SELECT record_key, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE zone_id = $1 AND is_deleted = false)
and shape them into NormalizedRecordSets. Compute buildDriftHistoryRows(...) BEFORE the
upsert (after the upsert the previous state is gone). Then upsert, then insert the drift
rows with
INSERT INTO route53_record_history (zone_id, record_key, record_name, record_type, change_action, before_value, after_value, source, changed_by_user_id, changed_by_email) VALUES (..., 'sync_detected_drift', NULL, NULL).
Insert drift rows in a batch (one multi-row INSERT or a loop inside
postgresClient.transaction()), and count them into the step's result so the sync summary
reports drift volume.
Guard the very first sync: if the mirror had zero rows for a zone, do NOT emit create
history rows for the entire zone (that would produce thousands of meaningless rows on
initial import). Detect this with a zone-level check — if prevByKey.size === 0, skip
history generation for that zone entirely and log
[ROUTE53-SYNC] Initial import for zone <id> — skipping drift history once.
Create lib/services/route53-sync-service.test.ts covering every <behavior> case by
calling buildDriftHistoryRows directly with hand-constructed maps. No AWS or Postgres
mocking is required for these cases. Import vitest primitives explicitly.
npx vitest run lib/services/route53-sync-service.test.ts && npx tsc --noEmit --pretty && npm test
<acceptance_criteria>
- npx vitest run lib/services/route53-sync-service.test.ts passes with cases for update, delete, create, and no-change
- buildDriftHistoryRows is exported from lib/services/route53-sync-service.ts and takes no database handle
- grep -c "'sync_detected_drift'" lib/services/route53-sync-service.ts >= 1
- The insert statement sets changed_by_user_id to NULL for drift rows (drift has no Pulse actor)
- Initial-import guard present: grep -q "prevByKey.size === 0" lib/services/route53-sync-service.ts
- Drift rows are computed before the upsert — the buildDriftHistoryRows call appears earlier in syncRecords() than the INSERT INTO route53_records statement
- npm test (full suite) exits 0
</acceptance_criteria>
Drift produces correctly tagged history rows, initial import does not flood the ledger, full suite green.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| AWS Route 53 API → Pulse sync service | Untrusted-shape external payloads (zone/record data) enter Postgres |
| Sync service → application logs | AWS SDK errors may carry request metadata |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-24-03 | Information Disclosure | console.error in Route53SyncService.executeSync and step catch blocks |
mitigate | Log error instanceof Error ? error.message : String(error) only; never pass the AWS SDK error object (which can include $metadata and request headers) to a logger. Asserted in Task 2 acceptance criteria. |
| T-24-04 | Repudiation | record changes made outside Pulse (AWS console, IaC) | mitigate | D-06 drift detection writes a route53_record_history row with source='sync_detected_drift' and whole-recordset before/after for every externally-changed record, making external mutation attributable-in-time even when the actor is unknown to Pulse. |
| T-24-10 | Denial of Service | initial import emitting one history row per record across all zones | mitigate | Zone-level initial-import guard (prevByKey.size === 0 → skip history) prevents an unbounded first-run write amplification into the append-only, never-purged ledger (D-08). |
| T-24-11 | Tampering | AWS-supplied record values written directly into Postgres JSONB | accept | Values are stored as data and rendered as text by the admin UI (plan 24-07 renders through React's default escaping, no dangerouslySetInnerHTML). Route 53 is itself the authoritative source; validating its own output against itself provides no security benefit. |
| T-24-05 | Tampering / Spoofing | live DNS record content | accept | Carried forward from plan 24-01 — D-03 accepts immediate execution with post-hoc audit only. |
| </threat_model> |
<success_criteria>
- Zones and records mirror into Postgres with pagination and soft-delete
- Drift produces
route53_record_historyrows taggedsync_detected_driftwith whole-recordset before/after - Equal recordsets produce zero history rows; initial import produces zero history rows
getRoute53SyncService()exported for plans 24-05 and 24-06- No
integration_settingsgating anywhere in this file (D-10) </success_criteria>