---
phase: 20-remediation-approval-audit-safety
plan: 02
type: execute
wave: 2
depends_on: [20-01]
files_modified:
  - lib/permissions.ts
  - 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
  - app/api/phishing/campaigns/[id]/classify/route.ts
autonomous: true
requirements: [REMED-02, REMED-03, REMED-04, REMED-05, REMED-06]

must_haves:
  truths:
    - "phishing:approve and phishing:remediate are granted to super-admin and admin only; a plain user (phishing:read) is rejected with 403"
    - "POST /approve records approver + timestamp + the exact operator-supplied action params (D-03 — request body specifies which recommended action(s) to approve and may override/narrow their params), gated by phishing:approve"
    - "POST /remediate is gated by phishing:remediate and returns the idempotent completion result; a malformed campaign id returns 400"
    - "POST /mark-false-positive is gated by phishing:approve and returns 409 when approved/completed remediation already exists"
    - "The classify route also writes a 'campaign_classified' audit event, completing REMED-06's four-action audit coverage"
  artifacts:
    - path: "lib/permissions.ts"
      provides: "approve+remediate granted to superAdminRole and adminRole (D-02)"
      contains: "phishing"
    - path: "app/api/phishing/campaigns/[id]/approve/route.ts"
      provides: "POST approve endpoint"
      exports: ["POST"]
      min_lines: 40
    - path: "app/api/phishing/campaigns/[id]/remediate/route.ts"
      provides: "POST remediate endpoint"
      exports: ["POST"]
      min_lines: 40
    - path: "app/api/phishing/campaigns/[id]/mark-false-positive/route.ts"
      provides: "POST mark-false-positive endpoint"
      exports: ["POST"]
      min_lines: 40
  key_links:
    - from: "app/api/phishing/campaigns/[id]/approve/route.ts"
      to: "approveRemediationActions"
      via: "requirePermission('phishing','approve') → service delegation"
      pattern: "requirePermission\\('phishing', ?'approve'\\)"
    - from: "app/api/phishing/campaigns/[id]/remediate/route.ts"
      to: "remediateApprovedActions"
      via: "requirePermission('phishing','remediate') → service delegation"
      pattern: "requirePermission\\('phishing', ?'remediate'\\)"
    - from: "app/api/phishing/campaigns/[id]/mark-false-positive/route.ts"
      to: "markCampaignFalsePositive"
      via: "requirePermission('phishing','approve') → service delegation"
      pattern: "requirePermission\\('phishing', ?'approve'\\)"
    - from: "app/api/phishing/campaigns/[id]/classify/route.ts"
      to: "writeAuditEvent"
      via: "post-classify audit write"
      pattern: "writeAuditEvent"
---

<objective>
Expose the Phase 20 service layer over HTTP: grant the elevated permissions
(D-02), add the three POST routes (approve / remediate / mark-false-positive)
following the Phase 18/19 route convention, and wire a 'campaign_classified'
audit event into the existing classify route so all four state-changing actions
are audited (REMED-06).

Purpose: The routes are the security perimeter — they enforce the permission
gate, validate the campaign id, capture the actor from the server session (never
the request body), and translate the service's typed errors into the right HTTP
status. Depends on Plan 01's service functions and writeAuditEvent.
Output: one permissions edit + three new routes + one classify-route edit.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/20-remediation-approval-audit-safety/20-CONTEXT.md
@.planning/phases/20-remediation-approval-audit-safety/20-PATTERNS.md
@.planning/phases/20-remediation-approval-audit-safety/20-01-SUMMARY.md

<interfaces>
<!-- Service surface delivered by Plan 01 — call these directly. -->
```typescript
// lib/services/remediation-service.ts
export class RemediationValidationError extends Error {}   // -> HTTP 400
export class RemediationConflictError extends Error {}     // -> HTTP 409
export interface ApproveActionInput { actionType: string; params?: Record<string, unknown>; }
export function approveRemediationActions(campaignId: string, actions: ApproveActionInput[], actor: string | null): Promise<...>;
export function remediateApprovedActions(campaignId: string, actor: string | null): Promise<...>;
export function markCampaignFalsePositive(campaignId: string, actor: string | null, reason?: string): Promise<...>;

// lib/services/phishing-audit.ts
export function writeAuditEvent(input: { campaignId: string; actor: string | null; eventType: string; payload: Record<string, unknown> }, client?): Promise<string>;
```

Route shell precedent — app/api/phishing/campaigns/[id]/classify/route.ts:
```typescript
import { NextRequest, NextResponse } from 'next/server';
import { requirePermission } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
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, { params }: { params: Promise<{ id: string }> }) { ... }
```

requirePermission (lib/auth-utils.ts) returns `{ session, error }`; internally
calls requireAuth() (401 if no session) then hasPermission(role, resource,
action) (403 if role lacks it). Actor capture precedent
(app/api/admin/integrations/route.ts line 80):
`const actor = (session?.user as { email?: string } | undefined)?.email ?? null;`

lib/permissions.ts current state: `statement.phishing` already =
`["read","analyze","approve","remediate"]` (full vocabulary from Phase 18).
`superAdminRole` and `adminRole` currently grant `phishing: ["read","analyze"]`;
`userRole` grants `phishing: ["read"]`.
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Grant approve + remediate to admin roles (D-02)</name>
  <files>lib/permissions.ts</files>
  <read_first>
    - lib/permissions.ts (full file — superAdminRole line 51, adminRole line 65, userRole line 79; the statement.phishing vocabulary line 34)
  </read_first>
  <action>
    Edit `superAdminRole` and `adminRole` so `phishing` becomes
    `["read", "analyze", "approve", "remediate"]` (add the `"approve"` and
    `"remediate"` array entries — D-02, parity with how `analyze` is granted to
    both). Do NOT change the `statement` object (line 34 already has the full
    vocabulary). Do NOT change `userRole` — it stays `phishing: ["read"]`, so a
    plain user cannot approve/remediate/mark-false-positive. Update the trailing
    comment on the superAdminRole line to drop the "ungranted until Phase 20"
    note. No new statement key is added (mark-false-positive reuses the
    `approve` action per D-04).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && node -e "const {hasPermission}=require('./lib/permissions.ts')" 2>/dev/null; grep -n "phishing:" lib/permissions.ts</automated>
  </verify>
  <acceptance_criteria>
    - `grep -c "phishing: \[\"read\", \"analyze\", \"approve\", \"remediate\"\]" lib/permissions.ts` returns 2 (superAdminRole + adminRole).
    - `grep -n "phishing: \[\"read\"\]" lib/permissions.ts` still matches userRole (unchanged).
    - The `statement` object is unchanged (still one `phishing:` line with the four-action vocabulary).
    - `npx tsc --noEmit` reports no errors.
  </acceptance_criteria>
  <done>super-admin and admin can approve/remediate; user remains read-only. D-02 satisfied.</done>
</task>

<task type="auto">
  <name>Task 2: approve + remediate routes</name>
  <files>app/api/phishing/campaigns/[id]/approve/route.ts, app/api/phishing/campaigns/[id]/remediate/route.ts</files>
  <read_first>
    - app/api/phishing/campaigns/[id]/classify/route.ts (route shell to copy verbatim: imports, UUID_RE, POST signature, campaign-exists SELECT, try/catch/500)
    - app/api/admin/integrations/route.ts (JSON body parse with try/catch → 400, PATCH lines 62-67; actor capture line 80)
    - lib/services/remediation-service.ts (approveRemediationActions / remediateApprovedActions signatures + error classes — Plan 01)
    - 20-CONTEXT.md D-03 (approve body = which recommended actions to approve, with optional param overrides)
  </read_first>
  <action>
    Create `approve/route.ts`. Copy the classify route shell. Use
    `const { session, error } = await requirePermission('phishing', 'approve');
    if (error) return error;` (destructure `session` — needed for the actor).
    Keep the `UUID_RE` guard → 400, and the campaign-exists SELECT → 404. Parse
    the JSON body in a try/catch → 400 on parse failure; expected shape per D-03
    is `{ actions: Array<{ actionType: string; params?: Record<string, unknown> }> }`.
    Validate `actions` is a non-empty array → 400 otherwise. Capture
    `const actor = (session?.user as { email?: string } | undefined)?.email ?? null;`.
    Delegate to `approveRemediationActions(id, body.actions, actor)`. Wrap in
    try/catch: catch `RemediationValidationError` → 400 with its message; catch
    `RemediationConflictError` → 409; otherwise log `[PHISHING-APPROVE]` and
    return 500. Return the service result as JSON on success.

    Create `remediate/route.ts` with the identical shell but
    `requirePermission('phishing', 'remediate')`, no request body needed (the
    action set is whatever was previously approved — D-01). Delegate to
    `remediateApprovedActions(id, actor)`. Same error mapping:
    RemediationValidationError → 400 (the "nothing approved" explicit failure,
    REMED-03), RemediationConflictError → 409, else `[PHISHING-REMEDIATE]` 500.
    Return the idempotent completion result as JSON.
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -n "requirePermission('phishing', 'approve')" app/api/phishing/campaigns/[id]/approve/route.ts && grep -n "requirePermission('phishing', 'remediate')" app/api/phishing/campaigns/[id]/remediate/route.ts</automated>
  </verify>
  <acceptance_criteria>
    - Both routes export an async `POST` and call `requirePermission` with `'approve'` / `'remediate'` respectively as the first statement, early-returning `error`.
    - `grep -n "UUID_RE" app/api/phishing/campaigns/[id]/approve/route.ts app/api/phishing/campaigns/[id]/remediate/route.ts` matches in both (malformed id → 400).
    - approve maps `RemediationValidationError`→400 and `RemediationConflictError`→409; grep confirms both catch branches.
    - Actor is derived from `session.user.email`, never from the request body (`grep -n "body.actor\|request.*actor" ...` returns nothing).
    - `npx tsc --noEmit` reports no errors.
  </acceptance_criteria>
  <done>approve and remediate endpoints exist, permission-gated, UUID-guarded, actor from session, service-delegated with correct status mapping.</done>
</task>

<task type="auto">
  <name>Task 3: mark-false-positive route + classify audit wiring</name>
  <files>app/api/phishing/campaigns/[id]/mark-false-positive/route.ts, app/api/phishing/campaigns/[id]/classify/route.ts</files>
  <read_first>
    - app/api/phishing/campaigns/[id]/approve/route.ts (route shell built in Task 2 — reuse its error-mapping pattern)
    - app/api/phishing/campaigns/[id]/classify/route.ts (existing route to extend with the audit write; it currently discards `session`)
    - lib/services/remediation-service.ts (markCampaignFalsePositive signature — Plan 01)
    - lib/services/phishing-audit.ts (writeAuditEvent — Plan 01)
    - 20-CONTEXT.md D-04 (mark-false-positive uses the elevated approve tier; blocked when approved/completed remediation exists)
  </read_first>
  <action>
    Create `mark-false-positive/route.ts`. Same shell as approve. Gate with
    `requirePermission('phishing', 'approve')` (D-04 — same elevated tier as
    approve; no separate action key). UUID guard → 400, campaign-exists → 404.
    Optionally parse a JSON body for `{ reason?: string }` in a try/catch
    (tolerate an empty/absent body — default reason undefined). Capture actor
    from session. Delegate to `markCampaignFalsePositive(id, actor, body?.reason)`.
    Error mapping: catch `RemediationConflictError` → 409 with its message (the
    D-04 block), `RemediationValidationError` → 400, else `[PHISHING-MARK-FP]`
    500. Return the service result as JSON.

    Edit `classify/route.ts` to complete REMED-06's four-action audit coverage:
    change `const { error } = await requirePermission('phishing', 'analyze')` to
    `const { session, error } = ...` (keep the same `'analyze'` action — do NOT
    change the classify permission). Import `writeAuditEvent` from
    `'@/lib/services/phishing-audit'`. After the successful `classifyCampaign(id)`
    call, capture `actor` from session and call
    `await writeAuditEvent({ campaignId: id, actor, eventType: 'campaign_classified',
    payload: { verdict: result.verdict, requiresApproval: result.requiresApproval } })`
    (no transaction client → uses postgresClient). Keep the existing try/catch
    and success response otherwise unchanged; a classification must not fail if
    the audit write throws only after the classification already committed —
    place the audit write inside the existing try so a failure surfaces as the
    500, but note classifyCampaign has already persisted its row (append-only).
  </action>
  <verify>
    <automated>npx tsc --noEmit --pretty && grep -n "requirePermission('phishing', 'approve')" app/api/phishing/campaigns/[id]/mark-false-positive/route.ts && grep -n "campaign_classified" app/api/phishing/campaigns/[id]/classify/route.ts</automated>
  </verify>
  <acceptance_criteria>
    - mark-false-positive route exports async `POST`, gates on `requirePermission('phishing', 'approve')`, and maps `RemediationConflictError`→409.
    - `grep -n "UUID_RE" app/api/phishing/campaigns/[id]/mark-false-positive/route.ts` matches.
    - classify route imports and calls `writeAuditEvent` with `eventType: 'campaign_classified'` after a successful classify.
    - classify route destructures `session` from requirePermission and derives actor from `session.user.email`.
    - `npx tsc --noEmit` reports no errors.
  </acceptance_criteria>
  <done>mark-false-positive endpoint exists (approve-tier, 409 on D-04 conflict) and classify writes an audit event — all four state-changing actions are now audited (REMED-06).</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| client → API route | Unauthenticated/under-privileged HTTP callers, malformed campaign ids, and attacker-controlled request bodies cross here |
| session → actor | The audited actor identity is derived here — must come from the verified server session, never the request |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-20-06 | Elevation of Privilege | all three POST routes | mitigate | `requirePermission('phishing','approve'\|'remediate')` early-return on every route (401 unauth, 403 unauthorized). D-02 grants approve/remediate to super-admin + admin only; `userRole` stays `["read"]`. mark-false-positive reuses the `approve` tier (D-04) — never a looser check. |
| T-20-07 | Spoofing / Repudiation | actor capture | mitigate | Actor is read from `session.user.email` (server session) and passed to the service for `approved_by` / `audit_events.actor`; it is never sourced from the request body, so a caller cannot forge the audited identity. |
| T-20-08 | Tampering | campaign id path param | mitigate | `UUID_RE` guard rejects malformed ids with 400 before any query; all campaign lookups are parameterized (`$1`), preventing injection and unhandled-500 leakage. |
| T-20-09 | Tampering | false-positive conflict bypass | mitigate | mark-false-positive delegates to the service's D-04 transactional guard; the route surfaces `RemediationConflictError` as 409 rather than silently succeeding, so the state machine cannot be forced into a contradictory state via HTTP. |
| T-20-10 | Repudiation | classify audit gap | mitigate | classify route now emits a `campaign_classified` audit event, closing REMED-06's requirement that ALL four state-changing actions (classify/approve/remediate/mark-false-positive) are audited. |
| T-20-SC | Tampering | npm/pip/cargo installs | n/a | This plan installs no packages — routes import only existing `@/lib/auth-utils`, `@/lib/services/postgres-client`, and the Plan 01 service modules. No install task; supply-chain checkpoint not required. |
</threat_model>

<verification>
- `npx tsc --noEmit --pretty` — clean across all edited/created files.
- Each route greps positive for its `requirePermission` action and the `UUID_RE` guard.
- classify route greps positive for `campaign_classified` / `writeAuditEvent`.
- lib/permissions.ts grants approve+remediate to exactly the two admin roles and leaves userRole read-only.
</verification>

<success_criteria>
- POST /api/phishing/campaigns/{id}/approve, /remediate, /mark-false-positive exist, are permission-gated (approve / remediate / approve tiers), UUID-guarded, and delegate to the Plan 01 service with correct 400/404/409/500 mapping.
- The audited actor always originates from the server session.
- Every state-changing action — classify, approve, remediate, mark-false-positive — writes an audit_events row (REMED-06 complete).
- A plain user role is rejected 403 from all three new endpoints.
</success_criteria>

<output>
Create `.planning/phases/20-remediation-approval-audit-safety/20-02-SUMMARY.md` when done.
</output>
