20 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 | 03 | execute | 2 |
|
|
true |
|
|
Purpose: SC-3 (every CRUD operation logged with actor, timestamp, before/after — including
failures per D-07) and the enforcement half of D-01. These live in lib/services/ rather
than in route handlers specifically so they are unit-testable — vitest.config.ts only
includes lib/**/*.test.ts, so validation logic embedded in app/api/** route files cannot
be covered by an automated test.
Output: lib/services/route53-record-validation.ts and
lib/services/route53-write-persistence.ts, both with test files.
<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/types/route53.ts: Route53WritableType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'SRV' Route53AuditStatus = 'pending' | 'committed' | 'failed' Route53HistorySource = 'pulse_crud' | 'sync_detected_drift' Route53RecordValue = { value: string }
Postgres (migration 102): route53_audit_log(id UUID PK DEFAULT gen_random_uuid(), operation CHECK IN ('create','update','delete','sync'), zone_id, record_key, record_name, record_type, before_value JSONB, after_value JSONB, performed_by_user_id TEXT FK->"user"(id) ON DELETE SET NULL, performed_by_email, performed_at, completed_at, status CHECK IN ('pending','committed','failed'), aws_change_id, aws_change_status, aws_response JSONB, error_message) 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) route53_records(record_key PK, zone_id, name, type, set_identifier, ttl, resource_records JSONB, alias_target JSONB, raw_payload JSONB, created_at, updated_at, synced_at, is_deleted, deleted_at)
lib/services/postgres-client.ts default export postgresClient:
.query(sql, params?) -> { rows: T[] }
Export WRITABLE_RECORD_TYPES as a frozen array of exactly the six D-01 types:
['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV']. NS and SOA must not appear anywhere in
this array. This is a closed allowlist, deliberately not a blocklist — an unrecognized type
is rejected rather than passed through.
Export validateRecordWrite(input: { name: unknown; type: unknown; ttl?: unknown; resourceRecords?: unknown }): { ok: true; value: ValidatedRecordWrite } | { ok: false; status: 400; reason: string }
where ValidatedRecordWrite is { name: string; type: Route53WritableType; ttl: number; resourceRecords: Route53RecordValue[] }.
Rules, in order, each returning a distinct reason string:
namemust be a non-empty string after trimming; normalize by lowercasing and appending a trailing dot if absent (Route 53's canonical form).typemust be a string; uppercase it, then it must be a member ofWRITABLE_RECORD_TYPES. When the uppercased type isNSorSOA, use an explicit reason naming zone delegation, e.g.'Record type NS is not writable from Pulse — NS and SOA are zone-delegation records (D-01)', so an operator understands the refusal rather than seeing a generic type error.ttlmust be an integer between 0 and 2147483647 inclusive; default to 300 when omitted.resourceRecordsmust be a non-empty array whose entries each have a non-empty stringvalue. Cap the array at 100 entries. Do not introduce Zod — CLAUDE.md says route handlers do not use it and this module is plain validation. Do not construct any AWS command here; this module has no AWS or DB dependency.
Export sanitizeAwsError(err: unknown): string (T-24-03). Take
err instanceof Error ? err.message : String(err), then redact with regex replacements:
/AKIA[0-9A-Z]{16}/g → '[redacted-key-id]', /arn:aws:[^\s"']+/g → '[redacted-arn]',
and /\b[0-9]{12}\b/g → '[redacted-account-id]' (12-digit AWS account ids). Then truncate
to 500 characters with a trailing ellipsis. This is the only string that may be written to
route53_audit_log.error_message or returned in an API response body.
Create lib/services/route53-record-validation.test.ts covering every <behavior> case.
Import vitest primitives explicitly (globals: false).
npx vitest run lib/services/route53-record-validation.test.ts && npx tsc --noEmit --pretty
<acceptance_criteria>
- npx vitest run lib/services/route53-record-validation.test.ts passes with at least 11 assertions covering every <behavior> bullet
- grep -c "'NS'\|'SOA'" lib/services/route53-record-validation.ts shows NS/SOA appearing only inside the rejection branch and its reason message, never inside WRITABLE_RECORD_TYPES
- The test file asserts rejection for both 'NS' and 'ns' (case-insensitivity) and for an unlisted type such as 'CAA'
- sanitizeAwsError test asserts an input containing AKIAIOSFODNN7EXAMPLE and arn:aws:route53:::hostedzone/Z123 produces a string containing neither substring
- grep -c "@aws-sdk\|postgres-client" lib/services/route53-record-validation.ts returns 0 — no AWS or DB dependency
- npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
D-01 allowlist is enforced by tested library code with a closed allowlist; AWS errors have a tested sanitizer.
createPendingAuditLog(input: { operation: 'create'|'update'|'delete'; zoneId: string; recordKey: string; recordName: string; recordType: string; beforeValue: unknown; afterValue: unknown; performedByUserId: string | null; performedByEmail: string | null }): Promise<{ id: string }>
— INSERT INTO route53_audit_log (operation, zone_id, record_key, record_name, record_type, before_value, after_value, performed_by_user_id, performed_by_email, status) VALUES ($1,...,$6::jsonb,$7::jsonb,...,'pending') RETURNING id::text AS id.
This must be callable and complete BEFORE any ChangeResourceRecordSetsCommand is
constructed — that discipline is the whole point of the pattern (24-RESEARCH.md Pattern 3:
"never write to the external system without an audit row already in flight").
markAuditCommitted(id: string, awsChangeId: string | null, awsChangeStatus: string | null, awsResponse: unknown): Promise<void>
— UPDATE route53_audit_log SET status = 'committed', completed_at = NOW(), aws_change_id = $2, aws_change_status = $3, aws_response = $4::jsonb WHERE id = $1.
markAuditFailed(id: string, err: unknown): Promise<void>
— UPDATE route53_audit_log SET status = 'failed', completed_at = NOW(), error_message = $2 WHERE id = $1, passing sanitizeAwsError(err) as $2 (D-07 + T-24-03). Never pass a raw
error object or JSON.stringify(err).
insertPulseCrudHistory(input: { zoneId: string; recordKey: string; recordName: string; recordType: string; changeAction: 'create'|'update'|'delete'; beforeValue: unknown; afterValue: unknown; changedByUserId: string | null; changedByEmail: string | null; auditLogId: string }): Promise<void>
— INSERT INTO route53_record_history (...) VALUES (..., 'pulse_crud', ...). Callers must
invoke this ONLY after markAuditCommitted — a failed AWS call changed nothing on AWS's
side, so it gets an audit row but no history row (24-RESEARCH.md Pattern 3, explicit).
Document that rule in a comment above the function.
upsertMirrorRecord(input: { recordKey, zoneId, name, type, setIdentifier, ttl, resourceRecords, aliasTarget, rawPayload }): Promise<void>
— best-effort refresh of route53_records after a committed write so the admin UI reflects
the change before the next scheduled sync. INSERT ... ON CONFLICT (record_key) DO UPDATE SET ... synced_at = NOW(), updated_at = NOW(), is_deleted = false, deleted_at = NULL.
softDeleteMirrorRecord(recordKey: string): Promise<void>
— UPDATE route53_records SET is_deleted = true, deleted_at = NOW(), updated_at = NOW() WHERE record_key = $1. Never hard-delete: route53_record_history references record_key
and the ledger is unbounded by design (D-08).
loadMirrorRecord(recordKey: string): Promise<MirrorRecordRow | null>
— SELECT record_key, zone_id, name, type, set_identifier, ttl, resource_records, alias_target FROM route53_records WHERE record_key = $1 AND is_deleted = false, returning a camelCase
object (manual snake_case→camelCase transform per CLAUDE.md, no ORM). This supplies the
before_value and, critically, the exact TTL and value set that a Route 53 DELETE action
requires to match (24-RESEARCH.md Pitfall 3 — a DELETE with a mismatched TTL or value set
fails or targets the wrong thing).
Wrap the mirror-refresh helpers so a failure there is logged ([ROUTE53-WRITE] prefix,
sanitizeAwsError) but does not throw — the AWS write already succeeded and the next
incremental sync reconciles the mirror regardless. The audit/history writes must NOT be
best-effort; let them throw.
Create lib/services/route53-write-persistence.test.ts verifying the SQL contract with a
mocked postgresClient: use vi.mock('@/lib/services/postgres-client', ...) (follow
whichever mocking style lib/services/pax8-sync-service.test.ts already uses in this repo)
and assert (a) createPendingAuditLog issues an INSERT whose SQL contains 'pending',
(b) markAuditFailed passes a sanitized string (an input containing AKIAIOSFODNN7EXAMPLE
does not appear in the bound parameters), (c) insertPulseCrudHistory binds the literal
'pulse_crud', (d) softDeleteMirrorRecord issues an UPDATE and never a DELETE FROM.
npx vitest run lib/services/route53-write-persistence.test.ts && npx tsc --noEmit --pretty && npm test
<acceptance_criteria>
- lib/services/route53-write-persistence.ts exports createPendingAuditLog, markAuditCommitted, markAuditFailed, insertPulseCrudHistory, upsertMirrorRecord, softDeleteMirrorRecord, loadMirrorRecord
- npx vitest run lib/services/route53-write-persistence.test.ts passes with the four assertions listed in the action
- grep -c 'DELETE FROM route53_records' lib/services/route53-write-persistence.ts returns 0 (soft-delete only, D-08)
- markAuditFailed calls sanitizeAwsError: grep -q 'sanitizeAwsError' lib/services/route53-write-persistence.ts
- grep -c "'pulse_crud'" lib/services/route53-write-persistence.ts >= 1
- A comment above insertPulseCrudHistory states that it must not be called on a failed AWS write
- npm test full suite exits 0
- npx tsc --noEmit --pretty exits 0
</acceptance_criteria>
Audit lifecycle and history persistence exist with tested SQL contracts; failures are sanitized; no hard deletes.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| HTTP request body → validation module | Untrusted operator-supplied record payload crosses into logic that will mutate live DNS |
| AWS SDK error → Postgres / HTTP response | Error text may carry account ids, ARNs, or key ids |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-24-01 | Tampering | record type field in a write request |
mitigate | validateRecordWrite enforces a closed allowlist of the six D-01 types in lib/services/route53-record-validation.ts, rejecting NS/SOA (case-insensitively) with status 400. Enforced in library code the routes call before constructing any AWS command — never relying on the UI hiding the option. Unit-tested. |
| T-24-03 | Information Disclosure | route53_audit_log.error_message and API error responses |
mitigate | sanitizeAwsError redacts AKIA-prefixed key ids, arn:aws:* strings, and 12-digit account ids, then truncates to 500 chars. It is the only permitted source of error_message values, asserted by a unit test. |
| T-24-04 | Repudiation | Pulse-initiated record writes | mitigate | createPendingAuditLog runs before any AWS call, capturing actor (performed_by_user_id/performed_by_email), timestamp, and before/after. A crashed process leaves a pending row, which is itself evidence an attempt occurred (SC-3). |
| T-24-07 | Tampering | audit/history rows treated as best-effort | mitigate | Audit and history writes intentionally throw on failure; only mirror-refresh helpers are best-effort. A DB failure must fail the request rather than silently produce an unlogged DNS mutation. |
| T-24-12 | Denial of Service | oversized resourceRecords array in a write request |
mitigate | Validation caps resourceRecords at 100 entries and rejects empty-string values before any AWS call. |
| T-24-05 | Tampering / Spoofing | semantic content of record values (dangling CNAME, SPF/DKIM TXT) | accept | Carried forward from plan 24-01 — D-03 accepts immediate execution with no pre-write approval gate; route53_audit_log before/after + actor is the compensating post-hoc control. Shape validation here explicitly does NOT attempt semantic threat detection. |
| </threat_model> |
<success_criteria>
- D-01 allowlist enforced by tested library code, closed (unknown types rejected)
- AWS errors sanitized before storage or client return
- pending → committed/failed lifecycle implemented with the audit row created before any AWS call
pulse_crudhistory rows written only after a committed write- Mirror updates are soft-delete only </success_criteria>