chore: merge executor worktree (worktree-agent-ae8d29f1083aba39d)
This commit is contained in:
commit
b7363f631c
6 changed files with 343 additions and 5 deletions
|
|
@ -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*
|
||||
76
app/api/phishing/campaigns/[id]/approve/route.ts
Normal file
76
app/api/phishing/campaigns/[id]/approve/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
75
app/api/phishing/campaigns/[id]/mark-false-positive/route.ts
Normal file
75
app/api/phishing/campaigns/[id]/mark-false-positive/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
63
app/api/phishing/campaigns/[id]/remediate/route.ts
Normal file
63
app/api/phishing/campaigns/[id]/remediate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue