feat(20-01): add approve + remediate remediation orchestrators
- approveRemediationActions validates each requested action against the campaign's latest classification.recommended_actions and materializes only recommended action types as status='approved' rows plus one atomic audit row (REMED-01, REMED-02, REMED-06) - remediateApprovedActions transitions approved rows to 'completed' (D-01 simulated internal effect, no external provider call), is idempotent via the status='approved' FOR UPDATE filter (REMED-04), and fails explicitly on zero remediation_actions rows (REMED-03) - RemediationValidationError / RemediationConflictError typed error classes Note: markCampaignFalsePositive (referenced by the already-committed test file) lands in the next commit (Task 3) — tsc will be clean again once that lands.
This commit is contained in:
parent
2937fe7bab
commit
3d63fab600
1 changed files with 207 additions and 0 deletions
207
lib/services/remediation-service.ts
Normal file
207
lib/services/remediation-service.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<ApprovedRemediationAction[]> {
|
||||
return postgresClient.transaction(async (client) => {
|
||||
const classificationRes = await client.query<ClassificationRow>(
|
||||
`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<InsertRemediationRow>(
|
||||
`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<RemediateResult> {
|
||||
return postgresClient.transaction(async (client) => {
|
||||
const rowsRes = await client.query<RemediationActionRow>(
|
||||
`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 };
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue