From 80e71297405a0e3e3a720fbc8150fdfcbf142d70 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 10:43:18 -0400 Subject: [PATCH 1/4] feat(20-02): grant phishing approve+remediate to admin roles (D-02) - superAdminRole and adminRole now include "approve" and "remediate" for phishing - userRole unchanged (still read-only) --- lib/permissions.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/permissions.ts b/lib/permissions.ts index a1a3e3b..e494930 100644 --- a/lib/permissions.ts +++ b/lib/permissions.ts @@ -29,8 +29,7 @@ export const statement = { // Datto RMM Overshell evidence (Phase 4.2 — read jobs / execute scripts) rmm: ["read", "execute"], - // Phishing triage campaigns/reports (Phase 18 — D-05: full vocabulary now; - // approve/remediate ungranted to any role until Phase 20) + // Phishing triage campaigns/reports (Phase 18 — D-05: full vocabulary) phishing: ["read", "analyze", "approve", "remediate"], } as const; @@ -48,7 +47,7 @@ export const superAdminRole = ac.newRole({ settings: ["read", "update"], itglue: ["read", "write"], rmm: ["read", "execute"], - phishing: ["read", "analyze"], // approve/remediate ungranted until Phase 20 + phishing: ["read", "analyze", "approve", "remediate"], }); // Admin role - access to admin panel and user management, but not role management @@ -62,7 +61,7 @@ export const adminRole = ac.newRole({ settings: ["read"], itglue: ["read", "write"], rmm: ["read", "execute"], - phishing: ["read", "analyze"], + phishing: ["read", "analyze", "approve", "remediate"], }); // User role - basic access From 65c4253f987bf767a926a26295129729324bd5a5 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 10:43:53 -0400 Subject: [PATCH 2/4] 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 } + ); + } +} From 1a126078d7c1dde0dfb8c502ee6d1b53a26ef29e Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 10:44:30 -0400 Subject: [PATCH 3/4] feat(20-02): add mark-false-positive route and classify audit event - POST /mark-false-positive: phishing:approve gated (D-04 elevated tier), optional reason body, delegates to markCampaignFalsePositive, maps RemediationConflictError->409 (already remediated) and RemediationValidationError->400 - classify route now writes a 'campaign_classified' audit event after a successful classification, completing REMED-06's four-action audit coverage (classify/approve/remediate/mark-false-positive) --- .../phishing/campaigns/[id]/classify/route.ts | 17 ++++- .../[id]/mark-false-positive/route.ts | 75 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/api/phishing/campaigns/[id]/mark-false-positive/route.ts diff --git a/app/api/phishing/campaigns/[id]/classify/route.ts b/app/api/phishing/campaigns/[id]/classify/route.ts index 74f5db2..eb61967 100644 --- a/app/api/phishing/campaigns/[id]/classify/route.ts +++ b/app/api/phishing/campaigns/[id]/classify/route.ts @@ -13,6 +13,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requirePermission } from '@/lib/auth-utils'; import postgresClient from '@/lib/services/postgres-client'; import { classifyCampaign } from '@/lib/services/campaign-classifier'; +import { writeAuditEvent } from '@/lib/services/phishing-audit'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -20,7 +21,7 @@ export async function POST( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { error } = await requirePermission('phishing', 'analyze'); + const { session, error } = await requirePermission('phishing', 'analyze'); if (error) return error; const { id } = await params; @@ -40,6 +41,20 @@ export async function POST( } const result = await classifyCampaign(id); + + // REMED-06: classify is the fourth state-changing action requiring audit + // coverage (alongside approve/remediate/mark-false-positive). The + // classification row has already been persisted above (append-only) — + // an audit-write failure surfaces as this route's 500, but the + // classification itself is not rolled back. + const actor = (session?.user as { email?: string } | undefined)?.email ?? null; + await writeAuditEvent({ + campaignId: id, + actor, + eventType: 'campaign_classified', + payload: { verdict: result.verdict, requiresApproval: result.requiresApproval }, + }); + return NextResponse.json(result); } catch (err) { console.error('[PHISHING-CLASSIFY] Failed to classify campaign', id, err); diff --git a/app/api/phishing/campaigns/[id]/mark-false-positive/route.ts b/app/api/phishing/campaigns/[id]/mark-false-positive/route.ts new file mode 100644 index 0000000..33ade1e --- /dev/null +++ b/app/api/phishing/campaigns/[id]/mark-false-positive/route.ts @@ -0,0 +1,75 @@ +/** + * POST /api/phishing/campaigns/[id]/mark-false-positive + * + * Marks a campaign as a false positive. Gated by phishing/approve (D-04 — + * same elevated tier as approve; no separate action key). Validates the + * campaign id as a UUID (V5), optionally accepts a JSON body with a `reason` + * string, and delegates to `markCampaignFalsePositive` (Plan 01), which + * guards against marking a campaign that already has approved/completed + * remediation (RemediationConflictError -> 409). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { requirePermission } from '@/lib/auth-utils'; +import postgresClient from '@/lib/services/postgres-client'; +import { + markCampaignFalsePositive, + 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', '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 }); + } + + // Body is optional — tolerate an empty/absent body (default reason undefined). + let reason: string | undefined; + const rawBody = await request.text(); + if (rawBody.trim().length > 0) { + try { + const parsed = JSON.parse(rawBody) as { reason?: unknown }; + reason = typeof parsed.reason === 'string' ? parsed.reason : undefined; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { 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 markCampaignFalsePositive(id, actor, reason); + return NextResponse.json(result); + } catch (err) { + if (err instanceof RemediationConflictError) { + return NextResponse.json({ error: err.message }, { status: 409 }); + } + if (err instanceof RemediationValidationError) { + return NextResponse.json({ error: err.message }, { status: 400 }); + } + console.error('[PHISHING-MARK-FP] Failed to mark campaign false positive', id, err); + return NextResponse.json( + { error: 'Failed to mark campaign false positive', message: err instanceof Error ? err.message : 'Unknown error' }, + { status: 500 } + ); + } +} From cd653143143c3a4ee6d1c29d7b8d41361d3b4c16 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 10:45:27 -0400 Subject: [PATCH 4/4] docs(20-02): complete remediation approval routes plan Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- .../20-02-SUMMARY.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .planning/phases/20-remediation-approval-audit-safety/20-02-SUMMARY.md diff --git a/.planning/phases/20-remediation-approval-audit-safety/20-02-SUMMARY.md b/.planning/phases/20-remediation-approval-audit-safety/20-02-SUMMARY.md new file mode 100644 index 0000000..33212bf --- /dev/null +++ b/.planning/phases/20-remediation-approval-audit-safety/20-02-SUMMARY.md @@ -0,0 +1,110 @@ +--- +phase: 20-remediation-approval-audit-safety +plan: 02 +subsystem: api +tags: [nextjs, api-routes, permissions, better-auth, phishing-triage, audit-log] + +# Dependency graph +requires: + - phase: 20-remediation-approval-audit-safety + provides: "Plan 01's transactional service layer (approveRemediationActions, remediateApprovedActions, markCampaignFalsePositive, writeAuditEvent) and its typed error classes" +provides: + - "lib/permissions.ts grants phishing:approve and phishing:remediate to super-admin and admin roles only (D-02); userRole stays read-only" + - "POST /api/phishing/campaigns/[id]/approve — permission-gated, D-03 action-list body, delegates to approveRemediationActions" + - "POST /api/phishing/campaigns/[id]/remediate — permission-gated, delegates to remediateApprovedActions (idempotent completion)" + - "POST /api/phishing/campaigns/[id]/mark-false-positive — approve-tier gated (D-04), delegates to markCampaignFalsePositive" + - "classify route now writes a 'campaign_classified' audit event, completing REMED-06's four-action audit coverage" +affects: [21-autotask-triage-note, 22-approval-ui-livelink] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Route shell precedent (from classify route) reused verbatim across all three new routes: requirePermission early-return, UUID_RE guard -> 400, campaign-exists SELECT -> 404, try/catch mapping typed service errors to HTTP status, actor derived from session.user.email never the request body" + +key-files: + created: + - "app/api/phishing/campaigns/[id]/approve/route.ts" + - "app/api/phishing/campaigns/[id]/remediate/route.ts" + - "app/api/phishing/campaigns/[id]/mark-false-positive/route.ts" + modified: + - "lib/permissions.ts" + - "app/api/phishing/campaigns/[id]/classify/route.ts" + +key-decisions: + - "mark-false-positive body parsing reads request.text() first and only JSON.parse()s if non-empty, so an absent/empty body is tolerated (no request.json() try/catch swallowing the 'no body sent' case as valid JSON parse failure)" + - "classify route's audit write happens inside the existing try/catch after classifyCampaign() succeeds; a write failure surfaces as a 500 even though the classification row has already committed (append-only, not rolled back) — documented inline" + +patterns-established: + - "Every state-changing phishing route destructures `session` (not just `error`) from requirePermission so the actor can be captured for the audit trail" + +requirements-completed: [REMED-02, REMED-03, REMED-04, REMED-05, REMED-06] + +# Metrics +duration: 10min +completed: 2026-07-16 +--- + +# Phase 20 Plan 02: Remediation Approval Routes Summary + +**Three new permission-gated POST routes (approve/remediate/mark-false-positive) wired to Plan 01's service layer, plus a 'campaign_classified' audit event added to the existing classify route, completing all four state-changing actions' audit coverage.** + +## Performance + +- **Duration:** ~10 min +- **Started:** 2026-07-16 (per orchestrator dispatch, wave 2) +- **Completed:** 2026-07-16 +- **Tasks:** 3 completed +- **Files modified:** 5 (3 created, 2 modified) + +## Accomplishments +- `lib/permissions.ts` — `superAdminRole` and `adminRole` now grant `phishing: ["read", "analyze", "approve", "remediate"]` (D-02); `userRole` unchanged at `["read"]`, so a plain user is rejected 403 from all three new endpoints +- `POST /api/phishing/campaigns/[id]/approve` — gated by `phishing:approve`, validates `{ actions: [{actionType, params?}] }` is a non-empty array (D-03), delegates to `approveRemediationActions`, maps `RemediationValidationError`→400 and `RemediationConflictError`→409 +- `POST /api/phishing/campaigns/[id]/remediate` — gated by `phishing:remediate`, no request body (D-01 — remediates whatever was previously approved), delegates to `remediateApprovedActions`, same error mapping +- `POST /api/phishing/campaigns/[id]/mark-false-positive` — gated by `phishing:approve` (D-04 elevated tier, no separate action key), optional `{ reason? }` body, delegates to `markCampaignFalsePositive`, maps the D-04 conflict guard to 409 +- `classify/route.ts` extended to destructure `session` and write a `campaign_classified` audit event (via `writeAuditEvent`) after a successful classification — REMED-06's four-action audit coverage (classify/approve/remediate/mark-false-positive) is now complete + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Grant approve + remediate to admin roles (D-02)** - `80e7129` (feat) +2. **Task 2: approve + remediate routes** - `65c4253` (feat) +3. **Task 3: mark-false-positive route + classify audit wiring** - `1a12607` (feat) + +**Plan metadata:** (this commit) + +## Files Created/Modified +- `lib/permissions.ts` - superAdminRole/adminRole grant phishing approve+remediate; userRole and the statement vocabulary unchanged +- `app/api/phishing/campaigns/[id]/approve/route.ts` - POST approve endpoint (D-03 action-list body) +- `app/api/phishing/campaigns/[id]/remediate/route.ts` - POST remediate endpoint (idempotent completion) +- `app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` - POST mark-false-positive endpoint (D-04 approve-tier gate) +- `app/api/phishing/campaigns/[id]/classify/route.ts` - now writes a `campaign_classified` audit event after successful classification + +## Decisions Made +- Body parsing in mark-false-positive uses `request.text()` + conditional `JSON.parse` rather than `request.json()` in a try/catch, so a genuinely empty body (no bytes sent) is treated as "no reason provided" rather than a parse failure — matches the plan's "tolerate an empty/absent body" instruction more precisely than a blanket try/catch around `request.json()` would (an empty body passed to `request.json()` throws, which would have incorrectly produced a 400 for the common case of "no reason given"). +- Kept the classify route's audit write inside the existing try/catch (as instructed) rather than isolating it in its own try/catch — a failure there surfaces as the route's existing 500 path, and the classification row itself is unaffected since it was already committed by `classifyCampaign` (append-only, no transaction spanning both). + +## Deviations from Plan + +None - plan executed exactly as written. All acceptance criteria (grep patterns for `requirePermission`, `UUID_RE`, `campaign_classified`, actor-from-session, and the two role permission-array greps) verified directly against the edited/created files. + +## Issues Encountered +None. + +## User Setup Required + +None - no external service configuration required. No package installs. + +## Next Phase Readiness +- All four phishing-triage state-changing actions (classify, approve, remediate, mark-false-positive) are now exposed over HTTP, permission-gated, and fully audited. +- Plan 21 (Autotask triage note) and Plan 22 (Approval UI) can call these four routes directly; the JSON response shapes match the Plan 01 service return types (`ApprovedRemediationAction[]`, `RemediateResult`, `MarkFalsePositiveResult`, `ClassifyResult`). +- No blockers. `npx tsc --noEmit --pretty` is clean at HEAD across all edited/created files. + +## Self-Check: PASSED + +All created files and commit hashes verified present on disk / in git log. + +--- +*Phase: 20-remediation-approval-audit-safety* +*Completed: 2026-07-16*