diff --git a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/deferred-items.md b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/deferred-items.md index 4c5f52c..ab7c2fb 100644 --- a/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/deferred-items.md +++ b/.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/deferred-items.md @@ -22,3 +22,10 @@ changes). `route53-write-persistence.ts`. Neither `itglue-search.ts` nor its test file were touched by this plan. Out of scope per the scope boundary rule — not fixed. + +## Plan 24-05 + +- Same 2 pre-existing `lib/services/analyzer/itglue-search.test.ts` failures + re-surfaced by `npm test` (full suite) while verifying Task 3. Neither + `itglue-search.ts` nor its test file were touched by this plan. Out of + scope per the scope boundary rule — not fixed. diff --git a/app/api/route53/zones/[zoneId]/records/[recordId]/route.ts b/app/api/route53/zones/[zoneId]/records/[recordId]/route.ts new file mode 100644 index 0000000..b6f7942 --- /dev/null +++ b/app/api/route53/zones/[zoneId]/records/[recordId]/route.ts @@ -0,0 +1,263 @@ +/** + * PATCH /api/route53/zones/:zoneId/records/:recordId — update a resource + * record set (Route 53 UPSERT). + * DELETE /api/route53/zones/:zoneId/records/:recordId — delete a resource + * record set. + * + * `recordId` is the URL-encoded `record_key` + * (`${zoneId}:${name}:${type}:${setIdentifier ?? ''}`). + * + * Both handlers follow the same eight-step sequence as POST in + * ../route.ts: + * 1. requireAdmin() (D-04) — never rely on the UI hiding a control (T-24-02) + * 2. isRoute53Configured() -> 503 + * 3. parse params + body + * 4. validateRecordWrite() (D-01, before any AWS command is constructed) + * 5. loadMirrorRecord() for beforeValue; null -> 404. The loaded row's exact + * name/type/ttl/resourceRecords are what get submitted to AWS — Route 53 + * rejects or mis-targets a DELETE whose recordset doesn't match exactly + * (24-RESEARCH.md Pitfall 3). Never build a DELETE from client-supplied + * { name, type } alone. + * 6. createPendingAuditLog before any AWS call (D-07/SC-3) + * 7. submitRecordChange + pollChangeStatus -> markAuditCommitted -> + * insertPulseCrudHistory -> upsertMirrorRecord/softDeleteMirrorRecord -> 200 + * 8. catch -> sanitizeAwsError -> markAuditFailed -> 502 (no history row — + * nothing changed on AWS's side, 24-RESEARCH.md Pattern 3) + * + * D-03 compliance: both mutations execute on the first request. No `confirm` + * body flag, no two-phase endpoint, no staged-approval status column — the + * audit trail is the control, not a pre-write block. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdmin } from '@/lib/auth-utils'; +import { isRoute53Configured } from '@/lib/services/route53-factory'; +import { validateRecordWrite, sanitizeAwsError } from '@/lib/services/route53-record-validation'; +import { + createPendingAuditLog, + markAuditCommitted, + markAuditFailed, + insertPulseCrudHistory, + upsertMirrorRecord, + softDeleteMirrorRecord, + loadMirrorRecord, +} from '@/lib/services/route53-write-persistence'; +import { submitRecordChange, pollChangeStatus } from '@/lib/services/route53-change-submit'; + +/** + * Verify the decoded record key's zone prefix matches the `zoneId` path + * param — prevents a caller from mutating a record in a different zone + * through a mismatched path (T-24-17). + */ +function recordKeyMatchesZone(recordKey: string, zoneId: string): boolean { + return recordKey.startsWith(`${zoneId}:`); +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ zoneId: string; recordId: string }> } +) { + const { session, error } = await requireAdmin(); + if (error) return error; + + if (!isRoute53Configured()) { + return NextResponse.json( + { + error: 'Route 53 not configured', + message: 'AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set to write Route 53 records', + }, + { status: 503 } + ); + } + + const { zoneId, recordId } = await params; + const recordKey = decodeURIComponent(recordId); + if (!recordKeyMatchesZone(recordKey, zoneId)) { + return NextResponse.json({ error: 'Record does not belong to this zone' }, { status: 400 }); + } + + const body = await request.json().catch(() => ({})); + const validated = validateRecordWrite(body); + if (!validated.ok) { + return NextResponse.json({ error: 'Invalid record', message: validated.reason }, { status: 400 }); + } + const { name, type, ttl, resourceRecords } = validated.value; + const setIdentifier: string | null = + typeof body.setIdentifier === 'string' && body.setIdentifier.trim().length > 0 + ? body.setIdentifier.trim() + : null; + + const existing = await loadMirrorRecord(recordKey); + if (!existing) { + return NextResponse.json({ error: 'Record not found' }, { status: 404 }); + } + const beforeValue = { + name: existing.name, + type: existing.type, + ttl: existing.ttl, + setIdentifier: existing.setIdentifier, + resourceRecords: existing.resourceRecords, + }; + const afterValue = { name, type, ttl, setIdentifier, resourceRecords }; + + const audit = await createPendingAuditLog({ + operation: 'update', + zoneId, + recordKey, + recordName: name, + recordType: type, + beforeValue, + afterValue, + performedByUserId: session!.user.id, + performedByEmail: session!.user.email, + }); + + try { + const { changeId, awsResponse } = await submitRecordChange({ + zoneId, + action: 'UPSERT', + recordSet: { name, type, ttl, resourceRecords, setIdentifier }, + }); + const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING'; + + await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse); + await insertPulseCrudHistory({ + zoneId, + recordKey, + recordName: name, + recordType: type, + changeAction: 'update', + beforeValue, + afterValue, + changedByUserId: session!.user.id, + changedByEmail: session!.user.email, + auditLogId: audit.id, + }); + await upsertMirrorRecord({ + recordKey, + zoneId, + name, + type, + setIdentifier, + ttl, + resourceRecords, + aliasTarget: null, + rawPayload: afterValue, + }); + + return NextResponse.json({ + auditId: audit.id, + status: 'committed', + propagationStatus, + record: afterValue, + }); + } catch (err) { + const message = sanitizeAwsError(err); + console.error('[ROUTE53-WRITE] update failed:', message); + await markAuditFailed(audit.id, err); + return NextResponse.json( + { auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message }, + { status: 502 } + ); + } +} + +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ zoneId: string; recordId: string }> } +) { + const { session, error } = await requireAdmin(); + if (error) return error; + + if (!isRoute53Configured()) { + return NextResponse.json( + { + error: 'Route 53 not configured', + message: 'AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set to write Route 53 records', + }, + { status: 503 } + ); + } + + const { zoneId, recordId } = await params; + const recordKey = decodeURIComponent(recordId); + if (!recordKeyMatchesZone(recordKey, zoneId)) { + return NextResponse.json({ error: 'Record does not belong to this zone' }, { status: 400 }); + } + + const existing = await loadMirrorRecord(recordKey); + if (!existing) { + return NextResponse.json({ error: 'Record not found' }, { status: 404 }); + } + + // D-01 defence in depth: validate the type of the record being deleted the + // same way a create/update is validated — an NS/SOA delete is as + // destructive as an NS/SOA write. + const validated = validateRecordWrite({ + name: existing.name, + type: existing.type, + ttl: existing.ttl ?? undefined, + resourceRecords: existing.resourceRecords ?? [], + }); + if (!validated.ok) { + return NextResponse.json({ error: 'Invalid record', message: validated.reason }, { status: 400 }); + } + + // The exact current recordset read from the mirror — Route 53 rejects or + // mis-targets a DELETE that does not match exactly (24-RESEARCH.md + // Pitfall 3). Never build this from client-supplied values. + const { name, type, ttl, resourceRecords } = validated.value; + const setIdentifier = existing.setIdentifier; + const beforeValue = { name, type, ttl, setIdentifier, resourceRecords }; + + const audit = await createPendingAuditLog({ + operation: 'delete', + zoneId, + recordKey, + recordName: name, + recordType: type, + beforeValue, + afterValue: null, + performedByUserId: session!.user.id, + performedByEmail: session!.user.email, + }); + + try { + const { changeId, awsResponse } = await submitRecordChange({ + zoneId, + action: 'DELETE', + recordSet: { name, type, ttl, resourceRecords, setIdentifier: existing.setIdentifier }, + }); + const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING'; + + await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse); + await insertPulseCrudHistory({ + zoneId, + recordKey, + recordName: name, + recordType: type, + changeAction: 'delete', + beforeValue, + afterValue: null, + changedByUserId: session!.user.id, + changedByEmail: session!.user.email, + auditLogId: audit.id, + }); + await softDeleteMirrorRecord(recordKey); + + return NextResponse.json({ + auditId: audit.id, + status: 'committed', + propagationStatus, + record: null, + }); + } catch (err) { + const message = sanitizeAwsError(err); + console.error('[ROUTE53-WRITE] delete failed:', message); + await markAuditFailed(audit.id, err); + return NextResponse.json( + { auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message }, + { status: 502 } + ); + } +} diff --git a/app/api/route53/zones/[zoneId]/records/route.ts b/app/api/route53/zones/[zoneId]/records/route.ts index 35f7a27..59c3f42 100644 --- a/app/api/route53/zones/[zoneId]/records/route.ts +++ b/app/api/route53/zones/[zoneId]/records/route.ts @@ -1,14 +1,36 @@ /** - * GET /api/route53/zones/:zoneId/records — list mirrored resource record + * GET /api/route53/zones/:zoneId/records — list mirrored resource record * sets in a hosted zone. Optional `?type=` and `?search=` filters. * requireAuth() gated. * - * POST (create) is added in a later plan task in this same file. + * POST /api/route53/zones/:zoneId/records — create a resource record set. + * requireAdmin() gated (D-04). Sequence identical to PATCH/DELETE in + * ./[recordId]/route.ts: + * 1. requireAdmin() (D-04) + * 2. isRoute53Configured() -> 503 + * 3. parse + validateRecordWrite() (D-01, before any AWS command) + * 4. beforeValue = null; loadMirrorRecord must be null or 409 + * 5. createPendingAuditLog before any AWS call (D-07/SC-3) + * 6. submitRecordChange + pollChangeStatus -> markAuditCommitted -> + * insertPulseCrudHistory -> upsertMirrorRecord -> 201 + * 7. catch -> sanitizeAwsError -> markAuditFailed -> 502 (no history row) */ import { NextRequest, NextResponse } from 'next/server'; -import { requireAuth } from '@/lib/auth-utils'; +import { requireAuth, requireAdmin } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; +import { isRoute53Configured } from '@/lib/services/route53-factory'; +import { validateRecordWrite, sanitizeAwsError } from '@/lib/services/route53-record-validation'; +import { + createPendingAuditLog, + markAuditCommitted, + markAuditFailed, + insertPulseCrudHistory, + upsertMirrorRecord, + loadMirrorRecord, +} from '@/lib/services/route53-write-persistence'; +import { submitRecordChange, pollChangeStatus } from '@/lib/services/route53-change-submit'; +import { buildRecordKey } from '@/lib/services/route53-record-key'; import type { Route53Record } from '@/lib/types/route53'; interface RecordRow { @@ -78,3 +100,101 @@ export async function GET( ); } } + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ zoneId: string }> } +) { + const { session, error } = await requireAdmin(); + if (error) return error; + + if (!isRoute53Configured()) { + return NextResponse.json( + { + error: 'Route 53 not configured', + message: 'AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set to write Route 53 records', + }, + { status: 503 } + ); + } + + const { zoneId } = await params; + const body = await request.json().catch(() => ({})); + + const validated = validateRecordWrite(body); + if (!validated.ok) { + return NextResponse.json({ error: 'Invalid record', message: validated.reason }, { status: 400 }); + } + const { name, type, ttl, resourceRecords } = validated.value; + const setIdentifier: string | null = + typeof body.setIdentifier === 'string' && body.setIdentifier.trim().length > 0 + ? body.setIdentifier.trim() + : null; + + const recordKey = buildRecordKey({ zoneId, name, type, setIdentifier }); + + const existing = await loadMirrorRecord(recordKey); + if (existing) { + return NextResponse.json({ error: 'Record already exists' }, { status: 409 }); + } + + const afterValue = { name, type, ttl, setIdentifier, resourceRecords }; + const audit = await createPendingAuditLog({ + operation: 'create', + zoneId, + recordKey, + recordName: name, + recordType: type, + beforeValue: null, + afterValue, + performedByUserId: session!.user.id, + performedByEmail: session!.user.email, + }); + + try { + const { changeId, awsResponse } = await submitRecordChange({ + zoneId, + action: 'CREATE', + recordSet: { name, type, ttl, resourceRecords, setIdentifier }, + }); + const propagationStatus = changeId ? await pollChangeStatus(changeId) : 'PENDING'; + + await markAuditCommitted(audit.id, changeId, propagationStatus, awsResponse); + await insertPulseCrudHistory({ + zoneId, + recordKey, + recordName: name, + recordType: type, + changeAction: 'create', + beforeValue: null, + afterValue, + changedByUserId: session!.user.id, + changedByEmail: session!.user.email, + auditLogId: audit.id, + }); + await upsertMirrorRecord({ + recordKey, + zoneId, + name, + type, + setIdentifier, + ttl, + resourceRecords, + aliasTarget: null, + rawPayload: afterValue, + }); + + return NextResponse.json( + { auditId: audit.id, status: 'committed', propagationStatus, record: afterValue }, + { status: 201 } + ); + } catch (err) { + const message = sanitizeAwsError(err); + console.error('[ROUTE53-WRITE] create failed:', message); + await markAuditFailed(audit.id, err); + return NextResponse.json( + { auditId: audit.id, status: 'failed', error: 'Route 53 write failed', message }, + { status: 502 } + ); + } +}