feat(24-05): CRUD write routes with pending/committed/failed audit lifecycle

- app/api/route53/zones/[zoneId]/records/route.ts: add POST (create)
- app/api/route53/zones/[zoneId]/records/[recordId]/route.ts: PATCH (update), DELETE
- All three write handlers: requireAdmin() first (D-04), validateRecordWrite() before
  any AWS command (D-01), createPendingAuditLog() before submitRecordChange() (D-07/SC-3)
- Committed path: markAuditCommitted -> insertPulseCrudHistory (pulse_crud, SC-4) ->
  mirror refresh; failed path: sanitizeAwsError -> markAuditFailed -> 502, no history row
- DELETE submits the exact mirror-read recordset (name/type/ttl/resourceRecords), never
  client-supplied values, per Route 53's exact-match delete requirement
- recordId zone-prefix mismatch guard (T-24-17): 400 before any audit row or AWS call
- No staged-approval mechanism anywhere (D-03) — mutation executes on first request
- tsc clean; npm test 554/556 passing (2 pre-existing itglue-search failures, unrelated,
  logged in deferred-items.md, already documented by plans 24-01/24-03)
This commit is contained in:
lorentz 2026-08-05 20:36:54 -04:00
parent 53ec51c5e0
commit a7d6a04110
3 changed files with 393 additions and 3 deletions

View file

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

View file

@ -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 }
);
}
}

View file

@ -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 }
);
}
}