From 65c4253f987bf767a926a26295129729324bd5a5 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 10:43:53 -0400 Subject: [PATCH] feat(20-02): add approve and remediate routes for phishing campaigns - POST /approve: phishing:approve gated, validates actions array (D-03), delegates to approveRemediationActions with actor from session - POST /remediate: phishing:remediate gated, delegates to remediateApprovedActions (idempotent completion, REMED-03/04) - Both UUID-guard the campaign id and map RemediationValidationError->400, RemediationConflictError->409 --- .../phishing/campaigns/[id]/approve/route.ts | 76 +++++++++++++++++++ .../campaigns/[id]/remediate/route.ts | 63 +++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 app/api/phishing/campaigns/[id]/approve/route.ts create mode 100644 app/api/phishing/campaigns/[id]/remediate/route.ts diff --git a/app/api/phishing/campaigns/[id]/approve/route.ts b/app/api/phishing/campaigns/[id]/approve/route.ts new file mode 100644 index 0000000..2b17200 --- /dev/null +++ b/app/api/phishing/campaigns/[id]/approve/route.ts @@ -0,0 +1,76 @@ +/** + * POST /api/phishing/campaigns/[id]/approve + * + * Operator approval of one or more recommended remediation actions (D-03). + * Gated by phishing/approve (D-02 — super-admin + admin only). Validates the + * campaign id as a UUID (V5), parses the request body's `actions` array, and + * delegates to `approveRemediationActions` (Plan 01), which validates each + * action against the campaign's latest classification's recommended_actions + * before materializing any rows. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + approveRemediationActions, + RemediationValidationError, + RemediationConflictError, + type ApproveActionInput, +} from '@/lib/services/remediation-service'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('phishing', 'approve'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed id would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } + + let body: { actions?: unknown }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!Array.isArray(body.actions) || body.actions.length === 0) { + return NextResponse.json({ error: '`actions` must be a non-empty array' }, { status: 400 }); + } + const actions = body.actions as ApproveActionInput[]; + + const actor = (session?.user as { email?: string } | undefined)?.email ?? null; + + try { + const campaignRes = await postgresClient.query<{ id: string }>( + `SELECT id FROM campaigns WHERE id = $1`, + [id] + ); + if (!campaignRes.rows[0]) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + const result = await approveRemediationActions(id, actions, actor); + return NextResponse.json(result); + } catch (err) { + if (err instanceof RemediationValidationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + if (err instanceof RemediationConflictError) { + return NextResponse.json({ error: err.message }, { status: 409 }); + } + console.error('[PHISHING-APPROVE] Failed to approve remediation actions', id, err); + return NextResponse.json( + { error: 'Failed to approve remediation actions', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/app/api/phishing/campaigns/[id]/remediate/route.ts b/app/api/phishing/campaigns/[id]/remediate/route.ts new file mode 100644 index 0000000..dd056fa --- /dev/null +++ b/app/api/phishing/campaigns/[id]/remediate/route.ts @@ -0,0 +1,63 @@ +/** + * POST /api/phishing/campaigns/[id]/remediate + * + * Executes (simulated, D-01) remediation for whatever was previously approved + * on this campaign — no request body needed, the action set was fixed at + * approval time. Gated by phishing/remediate (D-02 — super-admin + admin + * only). Validates the campaign id as a UUID (V5), then delegates to + * `remediateApprovedActions` (Plan 01), which is idempotent (REMED-04) and + * throws explicitly when nothing is approved (REMED-03). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + remediateApprovedActions, + RemediationValidationError, + RemediationConflictError, +} from '@/lib/services/remediation-service'; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requirePermission('phishing', 'remediate'); + if (error) return error; + + const { id } = await params; + // V5: validate UUID shape before querying — a malformed id would otherwise + // surface as an unhandled Postgres error -> uncaught 500. + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: 'Invalid campaign id' }, { status: 400 }); + } + + const actor = (session?.user as { email?: string } | undefined)?.email ?? null; + + try { + const campaignRes = await postgresClient.query<{ id: string }>( + `SELECT id FROM campaigns WHERE id = $1`, + [id] + ); + if (!campaignRes.rows[0]) { + return NextResponse.json({ error: 'Campaign not found' }, { status: 404 }); + } + + const result = await remediateApprovedActions(id, actor); + return NextResponse.json(result); + } catch (err) { + if (err instanceof RemediationValidationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + if (err instanceof RemediationConflictError) { + return NextResponse.json({ error: err.message }, { status: 409 }); + } + console.error('[PHISHING-REMEDIATE] Failed to remediate approved actions', id, err); + return NextResponse.json( + { error: 'Failed to remediate approved actions', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +}