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
This commit is contained in:
lorentz 2026-07-16 10:43:53 -04:00
parent 80e7129740
commit 65c4253f98
2 changed files with 139 additions and 0 deletions

View file

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

View file

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