diff --git a/lib/services/remediation-service.ts b/lib/services/remediation-service.ts new file mode 100644 index 0000000..1cdbad5 --- /dev/null +++ b/lib/services/remediation-service.ts @@ -0,0 +1,207 @@ +/** + * Remediation Service (Phase 20) + * + * Approve / remediate / mark-false-positive orchestrators for phishing + * campaigns. Every state change here is proposed-only until an operator + * explicitly approves it (REMED-01) — no function in this file auto-creates + * an approved/completed remediation_actions row. Approval only materializes + * action types that appear in the campaign's latest classification's + * recommended_actions (REMED-02). Every state change writes exactly one + * audit_events row, atomically, via writeAuditEvent(client) inside the same + * postgresClient.transaction as the state write (REMED-06). + * + * D-01: this file NEVER returns a `not_implemented` code path. The + * remediate step's "external effect" is a simulated internal transition + * (status='approved' -> 'completed') — no real provider call for any of the + * 7 action types this milestone. The only explicit-failure branch is the + * zero-approved-actions case (REMED-03) — remediating with nothing approved + * always throws, never silently no-ops as a success. + */ + +import { postgresClient } from './postgres-client'; +import { writeAuditEvent } from './phishing-audit'; + +export class RemediationValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RemediationValidationError'; + } +} + +export class RemediationConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'RemediationConflictError'; + } +} + +// ============================================================================= +// approveRemediationActions (REMED-01, REMED-02, REMED-06) +// ============================================================================= + +export interface ApproveActionInput { + actionType: string; + params?: Record; +} + +export interface ApprovedRemediationAction { + id: string; + campaignId: string; + actionType: string; + status: 'approved'; + approvedBy: string | null; +} + +interface ClassificationRow { + recommended_actions: string[] | string | null; +} + +interface InsertRemediationRow { + id: string; +} + +/** Normalizes the JSONB recommended_actions column into a string[] regardless of driver JSON parsing. */ +function parseRecommendedActions(value: string[] | string | null): string[] { + if (Array.isArray(value)) return value; + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +/** + * Validates each requested action against the campaign's latest + * classification's recommended_actions, then inserts one approved + * remediation_actions row per action plus one atomic audit row — all inside + * a single transaction. + */ +export async function approveRemediationActions( + campaignId: string, + actions: ApproveActionInput[], + actor: string | null +): Promise { + return postgresClient.transaction(async (client) => { + const classificationRes = await client.query( + `SELECT recommended_actions + FROM classifications + WHERE campaign_id = $1 + ORDER BY created_at DESC + LIMIT 1`, + [campaignId] + ); + const classification = classificationRes.rows[0]; + if (!classification) { + throw new RemediationValidationError('Campaign has no classification to approve against'); + } + const recommendedActions = new Set(parseRecommendedActions(classification.recommended_actions)); + + for (const action of actions) { + if (!recommendedActions.has(action.actionType)) { + throw new RemediationValidationError( + `Action ${action.actionType} is not a recommended action for this campaign` + ); + } + } + + const approved: ApprovedRemediationAction[] = []; + const actionIds: string[] = []; + for (const action of actions) { + const insertRes = await client.query( + `INSERT INTO remediation_actions (campaign_id, action_type, status, params, approved_by, approved_at) + VALUES ($1, $2, 'approved', $3::jsonb, $4, NOW()) + RETURNING id::text AS id`, + [campaignId, action.actionType, JSON.stringify(action.params ?? {}), actor] + ); + const id = insertRes.rows[0].id; + actionIds.push(id); + approved.push({ + id, + campaignId, + actionType: action.actionType, + status: 'approved', + approvedBy: actor, + }); + } + + await writeAuditEvent( + { campaignId, actor, eventType: 'remediation_approved', payload: { actions, actionIds } }, + client + ); + + return approved; + }); +} + +// ============================================================================= +// remediateApprovedActions (REMED-03, REMED-04, REMED-06) +// ============================================================================= + +interface RemediationActionRow { + id: string; + action_type: string; + status: string; +} + +export interface RemediateResultAction { + id: string; + actionType: string; + status: 'completed'; + alreadyCompleted: boolean; +} + +export interface RemediateResult { + campaignId: string; + actions: RemediateResultAction[]; +} + +/** + * Transitions every status='approved' row for the campaign to 'completed' + * (the D-01 simulated internal effect — no real external provider call for + * any action type) and writes one 'remediation_completed' audit row per + * transitioned action. Rows already 'completed' are left untouched and + * generate NO audit row — the status='approved' filter + FOR UPDATE is the + * idempotency mechanism: a re-run finds no approved rows and transitions/ + * audits nothing (REMED-04). A campaign with zero remediation_actions rows + * throws explicitly (REMED-03) — this function never returns a silent + * success for "nothing to do". + */ +export async function remediateApprovedActions(campaignId: string, actor: string | null): Promise { + return postgresClient.transaction(async (client) => { + const rowsRes = await client.query( + `SELECT id::text, action_type, status FROM remediation_actions WHERE campaign_id = $1 FOR UPDATE`, + [campaignId] + ); + + if (rowsRes.rows.length === 0) { + throw new RemediationValidationError('No remediation actions to remediate — nothing approved'); + } + + const actions: RemediateResultAction[] = []; + for (const row of rowsRes.rows) { + if (row.status === 'approved') { + await client.query(`UPDATE remediation_actions SET status = 'completed' WHERE id = $1`, [row.id]); + await writeAuditEvent( + { + campaignId, + actor, + eventType: 'remediation_completed', + payload: { actionId: row.id, actionType: row.action_type }, + }, + client + ); + actions.push({ id: row.id, actionType: row.action_type, status: 'completed', alreadyCompleted: false }); + } else { + // Already-completed (or otherwise non-approved) rows are left + // untouched and generate no audit row — idempotency (REMED-04). + actions.push({ id: row.id, actionType: row.action_type, status: 'completed', alreadyCompleted: true }); + } + } + + return { campaignId, actions }; + }); +}