304 lines
13 KiB
MySQL
304 lines
13 KiB
MySQL
|
|
-- Endpoint data model — anchor on configuration_items, sidecar everything else.
|
||
|
|
--
|
||
|
|
-- Adds three tables:
|
||
|
|
-- 1. device_external_ids — cross-reference: one row per (tool, device-in-tool).
|
||
|
|
-- 2. device_observations — time-series script/telemetry results (JSONB),
|
||
|
|
-- with optional B2 pointer for blobs that don't belong inline.
|
||
|
|
-- 3. endpoint_audits — LLM-produced audits anchored to a configuration_item.
|
||
|
|
-- Replaces itglue_asset_audits rows where asset_type='configuration'.
|
||
|
|
-- Flexible-asset audits stay in itglue_asset_audits (different lifecycle).
|
||
|
|
--
|
||
|
|
-- Backfill strategy is conservative: write source rows for every tool, but
|
||
|
|
-- only auto-link to a configuration_item when the match is deterministic
|
||
|
|
-- (Autotask's own rmm_device_uid for Datto). Hostname/serial fuzzy linking
|
||
|
|
-- is the reconciliation cron's job — it logs conflicts instead of merging.
|
||
|
|
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
-- 1. device_external_ids
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS device_external_ids (
|
||
|
|
id BIGSERIAL PRIMARY KEY,
|
||
|
|
configuration_item_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL,
|
||
|
|
source TEXT NOT NULL,
|
||
|
|
source_id TEXT NOT NULL,
|
||
|
|
hostname TEXT,
|
||
|
|
serial TEXT,
|
||
|
|
mac TEXT,
|
||
|
|
-- No FK on company_id: configuration_items already tolerates dangling
|
||
|
|
-- company_ids (Autotask retains CIs after company hard-deletes), and the
|
||
|
|
-- xref shouldn't be stricter than its anchor.
|
||
|
|
company_id BIGINT NULL,
|
||
|
|
last_seen_at TIMESTAMPTZ,
|
||
|
|
link_confidence TEXT,
|
||
|
|
linked_at TIMESTAMPTZ,
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
CONSTRAINT device_external_ids_source_check
|
||
|
|
CHECK (source IN ('autotask','datto_rmm','itglue','s1','veeam','addigy','auvik')),
|
||
|
|
CONSTRAINT device_external_ids_link_confidence_check
|
||
|
|
CHECK (link_confidence IS NULL OR link_confidence IN
|
||
|
|
('canonical','exact_uid','exact_serial','hostname_in_company','mac','manual')),
|
||
|
|
CONSTRAINT device_external_ids_unique UNIQUE (source, source_id)
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_external_ids_ci
|
||
|
|
ON device_external_ids(configuration_item_id) WHERE configuration_item_id IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_external_ids_unlinked
|
||
|
|
ON device_external_ids(source, last_seen_at) WHERE configuration_item_id IS NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_external_ids_hostname
|
||
|
|
ON device_external_ids(LOWER(hostname)) WHERE hostname IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_external_ids_serial
|
||
|
|
ON device_external_ids(serial) WHERE serial IS NOT NULL;
|
||
|
|
|
||
|
|
COMMENT ON TABLE device_external_ids IS
|
||
|
|
'Cross-reference from per-tool device IDs to a canonical Autotask configuration_item. configuration_item_id NULL = seen in tool X but no Autotask CI matched yet; reconciliation cron tries to link.';
|
||
|
|
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
-- 2. device_observations
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE TABLE IF NOT EXISTS device_observations (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
configuration_item_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL,
|
||
|
|
source TEXT NOT NULL,
|
||
|
|
kind TEXT NOT NULL,
|
||
|
|
collected_at TIMESTAMPTZ NOT NULL,
|
||
|
|
payload JSONB NOT NULL,
|
||
|
|
evidence_object_key TEXT,
|
||
|
|
run_id TEXT,
|
||
|
|
supersedes_id UUID NULL REFERENCES device_observations(id) ON DELETE SET NULL,
|
||
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
CONSTRAINT device_observations_source_check
|
||
|
|
CHECK (source IN ('autotask','datto_rmm','itglue','s1','veeam','addigy','auvik','loglift','overshell','manual'))
|
||
|
|
);
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_observations_ci_kind_time
|
||
|
|
ON device_observations(configuration_item_id, kind, collected_at DESC)
|
||
|
|
WHERE configuration_item_id IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_observations_kind_time
|
||
|
|
ON device_observations(kind, collected_at DESC);
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_device_observations_run_id
|
||
|
|
ON device_observations(run_id) WHERE run_id IS NOT NULL;
|
||
|
|
|
||
|
|
COMMENT ON TABLE device_observations IS
|
||
|
|
'Time-series telemetry / script results per endpoint. Payload is JSONB for direct querying; large blobs (raw event-log gzips, screenshots) live in B2 referenced by evidence_object_key.';
|
||
|
|
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
-- 3. endpoint_audits
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
-- An audit anchors on an Autotask configuration_item when one is known, but
|
||
|
|
-- IT Glue is also a valid anchor for devices we track there but not in
|
||
|
|
-- Autotask (orphans, IT-Glue-first onboarding). At least one must be set —
|
||
|
|
-- the CHECK below enforces it.
|
||
|
|
CREATE TABLE IF NOT EXISTS endpoint_audits (
|
||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
|
|
configuration_item_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL,
|
||
|
|
itglue_configuration_id BIGINT NULL,
|
||
|
|
organization_id BIGINT NULL,
|
||
|
|
generated_by_user_id TEXT NULL REFERENCES "user"(id) ON DELETE SET NULL,
|
||
|
|
generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
|
|
provider TEXT NOT NULL DEFAULT 'anthropic',
|
||
|
|
model_used TEXT,
|
||
|
|
asset_snapshot JSONB NOT NULL,
|
||
|
|
observations_consumed UUID[] NOT NULL DEFAULT '{}',
|
||
|
|
ticket_count INTEGER NOT NULL DEFAULT 0,
|
||
|
|
field_gaps JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||
|
|
notes_promotions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||
|
|
contradictions JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||
|
|
overall_score NUMERIC(3,2),
|
||
|
|
estimated_cost_usd NUMERIC(10,4),
|
||
|
|
total_input_tokens INTEGER,
|
||
|
|
total_output_tokens INTEGER,
|
||
|
|
status TEXT NOT NULL DEFAULT 'complete',
|
||
|
|
error_message TEXT,
|
||
|
|
triggered_by_ticket_number TEXT,
|
||
|
|
triggered_by_analysis_id UUID NULL REFERENCES analyzer_analyses(id) ON DELETE SET NULL,
|
||
|
|
triggered_by_observation_id UUID NULL REFERENCES device_observations(id) ON DELETE SET NULL,
|
||
|
|
legacy_itglue_audit_id UUID NULL,
|
||
|
|
CONSTRAINT endpoint_audits_provider_check
|
||
|
|
CHECK (provider IN ('anthropic','openrouter')),
|
||
|
|
CONSTRAINT endpoint_audits_status_check
|
||
|
|
CHECK (status IN ('pending','running','complete','failed')),
|
||
|
|
CONSTRAINT endpoint_audits_anchor_check
|
||
|
|
CHECK (configuration_item_id IS NOT NULL OR itglue_configuration_id IS NOT NULL)
|
||
|
|
);
|
||
|
|
|
||
|
|
-- Idempotent guard for re-runs against an already-created table.
|
||
|
|
ALTER TABLE endpoint_audits ADD COLUMN IF NOT EXISTS itglue_configuration_id BIGINT NULL;
|
||
|
|
DO $$
|
||
|
|
BEGIN
|
||
|
|
IF NOT EXISTS (
|
||
|
|
SELECT 1 FROM pg_constraint WHERE conname = 'endpoint_audits_anchor_check'
|
||
|
|
) THEN
|
||
|
|
ALTER TABLE endpoint_audits
|
||
|
|
ADD CONSTRAINT endpoint_audits_anchor_check
|
||
|
|
CHECK (configuration_item_id IS NOT NULL OR itglue_configuration_id IS NOT NULL);
|
||
|
|
END IF;
|
||
|
|
END $$;
|
||
|
|
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_endpoint_audits_ci_time
|
||
|
|
ON endpoint_audits(configuration_item_id, generated_at DESC) WHERE configuration_item_id IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_endpoint_audits_org_time
|
||
|
|
ON endpoint_audits(organization_id, generated_at DESC) WHERE organization_id IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_endpoint_audits_ticket
|
||
|
|
ON endpoint_audits(triggered_by_ticket_number) WHERE triggered_by_ticket_number IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_endpoint_audits_legacy
|
||
|
|
ON endpoint_audits(legacy_itglue_audit_id) WHERE legacy_itglue_audit_id IS NOT NULL;
|
||
|
|
CREATE INDEX IF NOT EXISTS ix_endpoint_audits_itglue_config
|
||
|
|
ON endpoint_audits(itglue_configuration_id, generated_at DESC) WHERE itglue_configuration_id IS NOT NULL;
|
||
|
|
|
||
|
|
COMMENT ON TABLE endpoint_audits IS
|
||
|
|
'LLM-produced audits anchored to a configuration_item. The configuration variant of itglue_asset_audits — flexible-asset audits stay in itglue_asset_audits since their lifecycle is different.';
|
||
|
|
COMMENT ON COLUMN endpoint_audits.legacy_itglue_audit_id IS
|
||
|
|
'Backfill pointer to the original itglue_asset_audits row this was migrated from. Drop after cutover is complete.';
|
||
|
|
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
-- Backfill: device_external_ids
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
-- Autotask: every CI is its own canonical row (the anchor).
|
||
|
|
INSERT INTO device_external_ids
|
||
|
|
(configuration_item_id, source, source_id, hostname, serial, mac, company_id,
|
||
|
|
last_seen_at, link_confidence, linked_at)
|
||
|
|
SELECT
|
||
|
|
ci.id,
|
||
|
|
'autotask',
|
||
|
|
ci.id::text,
|
||
|
|
ci.reference_title,
|
||
|
|
ci.serial_number,
|
||
|
|
ci.rmm_device_audit_mac_address,
|
||
|
|
ci.company_id,
|
||
|
|
ci.synced_at AT TIME ZONE 'UTC',
|
||
|
|
'canonical',
|
||
|
|
NOW()
|
||
|
|
FROM configuration_items ci
|
||
|
|
WHERE NOT EXISTS (
|
||
|
|
SELECT 1 FROM device_external_ids dx
|
||
|
|
WHERE dx.source = 'autotask' AND dx.source_id = ci.id::text
|
||
|
|
)
|
||
|
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
|
|
||
|
|
-- Datto RMM: link via Autotask's stored rmm_device_uid (deterministic).
|
||
|
|
-- Rows that don't match stay unlinked; reconciliation cron will retry by hostname.
|
||
|
|
INSERT INTO device_external_ids
|
||
|
|
(configuration_item_id, source, source_id, hostname, serial, mac, company_id,
|
||
|
|
last_seen_at, link_confidence, linked_at)
|
||
|
|
SELECT
|
||
|
|
ci.id,
|
||
|
|
'datto_rmm',
|
||
|
|
drd.uid,
|
||
|
|
drd.hostname,
|
||
|
|
NULL,
|
||
|
|
NULL,
|
||
|
|
ci.company_id,
|
||
|
|
drd.last_seen,
|
||
|
|
CASE WHEN ci.id IS NOT NULL THEN 'exact_uid' ELSE NULL END,
|
||
|
|
CASE WHEN ci.id IS NOT NULL THEN NOW() ELSE NULL END
|
||
|
|
FROM datto_rmm_devices drd
|
||
|
|
LEFT JOIN configuration_items ci ON ci.rmm_device_uid = drd.uid
|
||
|
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
|
|
||
|
|
-- IT Glue: stays unlinked initially. Reconciliation matches by serial → hostname.
|
||
|
|
INSERT INTO device_external_ids
|
||
|
|
(source, source_id, hostname, serial, mac, last_seen_at)
|
||
|
|
SELECT
|
||
|
|
'itglue',
|
||
|
|
igc.id::text,
|
||
|
|
COALESCE(igc.hostname, igc.name),
|
||
|
|
igc.serial_number,
|
||
|
|
igc.mac_address,
|
||
|
|
igc.synced_at
|
||
|
|
FROM itg_configurations igc
|
||
|
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
|
|
||
|
|
-- SentinelOne: stays unlinked initially. Reconciliation matches by computer_name + company.
|
||
|
|
INSERT INTO device_external_ids
|
||
|
|
(source, source_id, hostname, last_seen_at)
|
||
|
|
SELECT
|
||
|
|
's1',
|
||
|
|
sa.id::text,
|
||
|
|
sa.computer_name,
|
||
|
|
sa.last_active_date AT TIME ZONE 'UTC'
|
||
|
|
FROM s1_agents sa
|
||
|
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
|
|
||
|
|
-- Veeam: stays unlinked initially. Reconciliation matches by hostname (the
|
||
|
|
-- agent's `name` field). No serial / MAC available.
|
||
|
|
INSERT INTO device_external_ids
|
||
|
|
(source, source_id, hostname, last_seen_at)
|
||
|
|
SELECT
|
||
|
|
'veeam',
|
||
|
|
vba.instance_uid,
|
||
|
|
vba.name,
|
||
|
|
vba.synced_at
|
||
|
|
FROM veeam_backup_agents vba
|
||
|
|
ON CONFLICT (source, source_id) DO NOTHING;
|
||
|
|
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
-- Backfill: endpoint_audits from itglue_asset_audits (configuration only)
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
-- We snapshot the configuration audits into endpoint_audits but keep the
|
||
|
|
-- originals in itglue_asset_audits until the receiver flips. legacy_itglue_audit_id
|
||
|
|
-- preserves the link both ways during the dual-source window.
|
||
|
|
|
||
|
|
-- itglue_asset_audits.asset_id (when asset_type='configuration') is an IT Glue
|
||
|
|
-- Configuration ID, not an Autotask CI. We anchor primarily on IT Glue here
|
||
|
|
-- and let the reconciler later fill in configuration_item_id via the xref.
|
||
|
|
INSERT INTO endpoint_audits (
|
||
|
|
configuration_item_id, itglue_configuration_id,
|
||
|
|
organization_id, generated_by_user_id, generated_at,
|
||
|
|
provider, model_used, asset_snapshot, ticket_count,
|
||
|
|
field_gaps, notes_promotions, contradictions,
|
||
|
|
overall_score, estimated_cost_usd, total_input_tokens, total_output_tokens,
|
||
|
|
status, error_message, triggered_by_ticket_number, triggered_by_analysis_id,
|
||
|
|
legacy_itglue_audit_id
|
||
|
|
)
|
||
|
|
SELECT
|
||
|
|
dx.configuration_item_id,
|
||
|
|
ia.asset_id,
|
||
|
|
ia.organization_id,
|
||
|
|
ia.generated_by_user_id,
|
||
|
|
ia.generated_at,
|
||
|
|
ia.provider,
|
||
|
|
ia.model_used,
|
||
|
|
ia.asset_snapshot,
|
||
|
|
ia.ticket_count,
|
||
|
|
ia.field_gaps,
|
||
|
|
ia.notes_promotions,
|
||
|
|
ia.contradictions,
|
||
|
|
ia.overall_score,
|
||
|
|
ia.estimated_cost_usd,
|
||
|
|
ia.total_input_tokens,
|
||
|
|
ia.total_output_tokens,
|
||
|
|
ia.status,
|
||
|
|
ia.error_message,
|
||
|
|
ia.triggered_by_ticket_number,
|
||
|
|
ia.triggered_by_analysis_id,
|
||
|
|
ia.id
|
||
|
|
FROM itglue_asset_audits ia
|
||
|
|
LEFT JOIN device_external_ids dx
|
||
|
|
ON dx.source = 'itglue' AND dx.source_id = ia.asset_id::text
|
||
|
|
WHERE ia.asset_type = 'configuration'
|
||
|
|
AND NOT EXISTS (
|
||
|
|
SELECT 1 FROM endpoint_audits ea WHERE ea.legacy_itglue_audit_id = ia.id
|
||
|
|
);
|
||
|
|
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
-- updated_at trigger for device_external_ids
|
||
|
|
-- ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
CREATE OR REPLACE FUNCTION device_external_ids_set_updated_at() RETURNS TRIGGER AS $$
|
||
|
|
BEGIN
|
||
|
|
NEW.updated_at = NOW();
|
||
|
|
RETURN NEW;
|
||
|
|
END;
|
||
|
|
$$ LANGUAGE plpgsql;
|
||
|
|
|
||
|
|
DROP TRIGGER IF EXISTS trg_device_external_ids_updated_at ON device_external_ids;
|
||
|
|
CREATE TRIGGER trg_device_external_ids_updated_at
|
||
|
|
BEFORE UPDATE ON device_external_ids
|
||
|
|
FOR EACH ROW EXECUTE FUNCTION device_external_ids_set_updated_at();
|