wulf-pulse/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-03-PLAN.md

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
24-01
lib/services/route53-record-validation.ts
lib/services/route53-record-validation.test.ts
lib/services/route53-write-persistence.ts
lib/services/route53-write-persistence.test.ts
true
SC-3
truths artifacts key_links
D-01: A server-side validator rejects any write targeting record type NS or SOA, and accepts only A, AAAA, CNAME, MX, TXT, SRV — the rejection happens in library code the routes call before any AWS command is constructed, not in the UI
SC-3/D-07: An audit row is created with status='pending' BEFORE any AWS call is made, and is transitioned to 'committed' or 'failed' after — no write to Route 53 can occur without an audit row already in flight
SC-3/D-07: A failed AWS attempt leaves a route53_audit_log row with status='failed', a sanitized error_message, and the attempted before/after values — failures are as auditable as successes
T-24-03: Error messages persisted and returned are sanitized — capped in length and stripped of AWS account ARNs, request ids, and access key ids before storage or client return
path provides exports
lib/services/route53-record-validation.ts D-01 writable-type allowlist + record-shape validation, unit-testable without a request
WRITABLE_RECORD_TYPES
validateRecordWrite
sanitizeAwsError
path provides exports
lib/services/route53-write-persistence.ts pending/committed/failed audit lifecycle + pulse_crud history rows + mirror refresh
createPendingAuditLog
markAuditCommitted
markAuditFailed
insertPulseCrudHistory
upsertMirrorRecord
softDeleteMirrorRecord
path provides
lib/services/route53-record-validation.test.ts allowlist and sanitizer coverage
from to via pattern
lib/services/route53-record-validation.ts lib/types/route53.ts Route53WritableType import Route53WritableType
from to via pattern
lib/services/route53-write-persistence.ts route53_audit_log INSERT ... status='pending' / UPDATE ... status='committed'|'failed' route53_audit_log
from to via pattern
lib/services/route53-write-persistence.ts route53_record_history INSERT with source='pulse_crud' pulse_crud
Build the two library modules the CRUD routes in plan 24-05 depend on: a server-side record validator enforcing D-01's writable-type allowlist, and a write-persistence module implementing the pending → committed/failed audit lifecycle (the single most important pattern in this phase, lifted from the IT Glue write-back precedent).

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.md

lib/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[] }

Task 1: Server-side record validation and AWS error sanitizer lib/services/route53-record-validation.ts, lib/services/route53-record-validation.test.ts - lib/types/route53.ts (Route53WritableType — created in plan 24-01) - app/api/analyzer/itglue/applications/[id]/apply/route.ts lines 86-94 (the credential-field blocklist — the exact "belt over the UI's braces" defensive-check pattern to mirror) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-RESEARCH.md (Security Domain: ASVS V5 note, Known Threat Patterns table) - .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-CONTEXT.md (D-01) - `validateRecordWrite({ type: 'NS', ... })` returns `{ ok: false, status: 400, reason: }` - `validateRecordWrite({ type: 'SOA', ... })` returns `{ ok: false, status: 400 }` - `validateRecordWrite({ type: 'ns', ... })` (lowercase) is also rejected — the check is case-insensitive - Each of `A`, `AAAA`, `CNAME`, `MX`, `TXT`, `SRV` with a well-formed payload returns `{ ok: true }` - An unknown type such as `CAA` or `DS` returns `{ ok: false, status: 400 }` — the allowlist is closed, not a blocklist - Missing or empty `name` returns `{ ok: false, status: 400 }` - `ttl` outside 0..2147483647, or non-integer, returns `{ ok: false, status: 400 }` - An empty `resourceRecords` array returns `{ ok: false, status: 400 }` (Route 53 rejects an empty value set) - A `resourceRecords` entry with an empty-string value returns `{ ok: false, status: 400 }` - `sanitizeAwsError` strips anything matching an AWS access key id pattern (`AKIA` followed by 16 alphanumerics), any `arn:aws:` substring through the following whitespace, and truncates the result to 500 characters - `sanitizeAwsError` on a non-Error input returns a string, never throws Create `lib/services/route53-record-validation.ts`.

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:

  1. name must be a non-empty string after trimming; normalize by lowercasing and appending a trailing dot if absent (Route 53's canonical form).
  2. type must be a string; uppercase it, then it must be a member of WRITABLE_RECORD_TYPES. When the uppercased type is NS or SOA, 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.
  3. ttl must be an integer between 0 and 2147483647 inclusive; default to 300 when omitted.
  4. resourceRecords must be a non-empty array whose entries each have a non-empty string value. 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.

Task 2: Audit lifecycle and pulse_crud history persistence lib/services/route53-write-persistence.ts, lib/services/route53-write-persistence.test.ts - lib/services/analyzer/asset-audit/persistence.ts lines 321-379 (createPendingWrite / markWriteCommitted / markWriteFailed — the exact three-function shape to mirror) - migrations/075_itglue_audit.sql lines 60-88 (itglue_writes precedent) - migrations/102_route53_tables.sql (route53_audit_log and route53_record_history columns) - lib/services/route53-record-validation.ts (sanitizeAwsError — created in Task 1) - lib/services/postgres-client.ts (query signature and parameter binding style) - lib/services/route53-record-key.ts (buildRecordKey — created in plan 24-02 Task 1; if plan 24-02 has not landed, import path is still `@/lib/services/route53-record-key`) Create `lib/services/route53-write-persistence.ts` following `lib/services/analyzer/asset-audit/persistence.ts`'s three-function shape exactly.

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>
- `npx vitest run lib/services/route53-record-validation.test.ts lib/services/route53-write-persistence.test.ts` green - `npm test` full suite green - `npx tsc --noEmit --pretty` exits 0 - `grep -rn "NS\b" lib/services/route53-record-validation.ts` confirms NS appears only in the rejection path

<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_crud history rows written only after a committed write
  • Mirror updates are soft-delete only </success_criteria>
Create `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-03-SUMMARY.md` when done.