From 84a37e20be9240bd76b56e542f110e3a0c4bfd2d Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 15 Jul 2026 07:35:10 -0400 Subject: [PATCH] feat(15-01): add phishing-triage schema migration 097 - 7-table schema: campaigns, reports, messages, indicators, classifications, remediation_actions, audit_events - reports table fully designed with ticket_id FK, content_hash (D-04 idempotency), matched_patterns, evidence (EVID-01) columns - All tables/indexes use IF NOT EXISTS for idempotent re-application - Remaining 6 tables are stubs for Phases 16-21 --- migrations/097_phishing_triage_schema.sql | 157 ++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 migrations/097_phishing_triage_schema.sql diff --git a/migrations/097_phishing_triage_schema.sql b/migrations/097_phishing_triage_schema.sql new file mode 100644 index 0000000..b0dde4f --- /dev/null +++ b/migrations/097_phishing_triage_schema.sql @@ -0,0 +1,157 @@ +-- Phishing Triage — Postgres schema (v3.0). +-- +-- Schema-only migration (Phase 15) — lays down the full durable schema every +-- v3.0 phase reads and writes, before any service writes to it. +-- +-- • Campaign grouping (dedupe multiple reports of the same lure) -> campaigns +-- • One row per reported/triaged ticket (Phase 15 populates this) -> reports +-- • Parsed email content per report -> messages (stub, Phase 16) +-- • Extracted URLs/senders/hashes per message -> indicators (stub, Phase 16) +-- • Campaign verdict + recommended actions -> classifications (stub, Phase 19) +-- • Proposed/approved remediation actions per campaign -> remediation_actions (stub, Phase 20) +-- • Audit trail of triage/remediation events -> audit_events (stub, Phase 20) +-- +-- Phase 15 only populates `reports` (via the Plan 02 phishing-detector +-- service). `campaigns`, `messages`, `indicators`, `classifications`, +-- `remediation_actions`, and `audit_events` are laid down now as stubs so +-- Phases 16-21 have their schema ready and never need a second foundation +-- migration. Postgres 16 has built-in gen_random_uuid() — no extension needed. + +-- --------------------------------------------------------------------------- +-- 1. campaigns — groups multiple reports of the same phishing lure +-- --------------------------------------------------------------------------- +-- Populated starting Phase 18 (campaign grouping). reports.campaign_id is +-- nullable until then. + +CREATE TABLE IF NOT EXISTS campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_key TEXT, + group_method TEXT, + first_seen_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ, + report_count INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'open', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_campaigns_campaign_key ON campaigns(campaign_key); + +-- --------------------------------------------------------------------------- +-- 2. reports — one row per reported/triaged ticket (the only table Phase 15 +-- populates; fully designed here) +-- --------------------------------------------------------------------------- +-- ticket_id is a hard FK to tickets(id) — every report originates from an +-- Autotask ticket already synced into Postgres. content_hash is the D-04 +-- idempotency key (hash over title+description) the Plan 02 detector uses to +-- avoid re-processing an unchanged ticket. matched_patterns captures the +-- DETECT-01 pattern strings that matched. evidence captures the EVID-01 +-- notes/time_entries/attachments snapshot at detection time. campaign_id is +-- nullable — Phase 18's campaign-grouping logic links it later. +-- uq_reports_ticket_id lets the Plan 02 detector upsert with +-- ON CONFLICT (ticket_id) — one report per ticket. + +CREATE TABLE IF NOT EXISTS reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id BIGINT NOT NULL REFERENCES tickets(id), + ticket_number VARCHAR(100), + company_id BIGINT, + company_name VARCHAR(255), + requester_contact_id BIGINT, + created_by_contact_id BIGINT, + title VARCHAR(255), + description TEXT, + matched_patterns JSONB NOT NULL DEFAULT '[]'::jsonb, + content_hash TEXT NOT NULL, + evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + campaign_id UUID REFERENCES campaigns(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_reports_ticket_id UNIQUE (ticket_id) +); + +CREATE INDEX IF NOT EXISTS idx_reports_ticket_id ON reports(ticket_id); +CREATE INDEX IF NOT EXISTS idx_reports_content_hash ON reports(content_hash); +CREATE INDEX IF NOT EXISTS idx_reports_campaign_id ON reports(campaign_id); + +-- --------------------------------------------------------------------------- +-- 3. messages — parsed email content per report (stub for Phase 16) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + report_id UUID REFERENCES reports(id), + message_id TEXT, + headers JSONB, + urls JSONB, + attachments JSONB, + body_preview TEXT, + raw_ref TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_messages_report_id ON messages(report_id); +CREATE INDEX IF NOT EXISTS idx_messages_message_id ON messages(message_id); + +-- --------------------------------------------------------------------------- +-- 4. indicators — extracted URLs/senders/hashes per message (stub for Phase 16) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS indicators ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_id UUID REFERENCES messages(id), + indicator_type TEXT NOT NULL, + value TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_indicators_message_id ON indicators(message_id); + +-- --------------------------------------------------------------------------- +-- 5. classifications — campaign verdict + recommended actions (stub for Phase 19) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS classifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID REFERENCES campaigns(id), + verdict TEXT, + confidence NUMERIC, + summary TEXT, + reasons JSONB, + recommended_actions JSONB, + requires_approval BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_classifications_campaign_id ON classifications(campaign_id); + +-- --------------------------------------------------------------------------- +-- 6. remediation_actions — proposed/approved remediation per campaign (stub +-- for Phase 20) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS remediation_actions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID REFERENCES campaigns(id), + action_type TEXT, + status TEXT NOT NULL DEFAULT 'proposed', + params JSONB, + approved_by TEXT, + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_remediation_actions_campaign_id ON remediation_actions(campaign_id); + +-- --------------------------------------------------------------------------- +-- 7. audit_events — audit trail of triage/remediation events (stub for Phase 20) +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS audit_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID, + actor TEXT, + event_type TEXT NOT NULL, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +);