wulf-pulse/lib/services/device-link-reconciler.ts
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

249 lines
7.7 KiB
TypeScript

/**
* Device-link reconciler — links unlinked device_external_ids rows to a
* configuration_item. Cascading match strategies, highest confidence first.
* Conflicts (multiple matches) are logged for admin review, not auto-merged.
*
* Wire into sync-scheduler.ts as an hourly cron when ready. Not wired yet —
* reviewer should approve the match strategies + conflict policy first.
*/
import postgresClient from '@/lib/services/postgres-client';
type LinkConfidence = 'canonical' | 'exact_uid' | 'exact_serial' | 'hostname_in_company' | 'mac' | 'manual';
interface UnlinkedRow {
id: number;
source: string;
source_id: string;
hostname: string | null;
serial: string | null;
mac: string | null;
company_id: number | null;
}
interface MatchCandidate {
configuration_item_id: number;
link_confidence: LinkConfidence;
}
export interface ReconcileResult {
scanned: number;
linked: number;
conflicts: number;
unmatched: number;
byConfidence: Record<LinkConfidence, number>;
}
const LINK_CONFIDENCE_RANK: Record<LinkConfidence, number> = {
canonical: 100,
exact_uid: 90,
exact_serial: 80,
mac: 70,
hostname_in_company: 60,
manual: 50,
};
// Common BIOS/inventory placeholder serials that shouldn't be matched on —
// hundreds of unrelated CIs share these and any link based on them is noise.
const PLACEHOLDER_SERIALS = new Set([
'', '0', '1', 'n/a', 'na', 'none', 'null', 'unknown', 'not listed',
'not specified', 'not applicable', 'default string', 'to be filled by o.e.m.',
'system serial number', 'chassis serial number',
'0000000000', '00000000', 'ffffffffffff',
'00000000-0000-0000-0000-000000000000',
]);
function isPlaceholderSerial(serial: string): boolean {
const s = serial.trim().toLowerCase();
if (s.length < 4) return true;
if (PLACEHOLDER_SERIALS.has(s)) return true;
// Strings that are all the same character (e.g. "00000000", "FFFFFFFF").
if (/^(.)\1+$/.test(s)) return true;
return false;
}
async function findBySerial(serial: string): Promise<MatchCandidate[]> {
if (isPlaceholderSerial(serial)) return [];
const res = await postgresClient.query<{ id: string }>(
`SELECT id::text FROM configuration_items
WHERE serial_number IS NOT NULL
AND serial_number = $1
AND (is_deleted IS NULL OR is_deleted = false)`,
[serial]
);
return res.rows.map((r) => ({
configuration_item_id: Number(r.id),
link_confidence: 'exact_serial' as const,
}));
}
async function findByMac(mac: string): Promise<MatchCandidate[]> {
const res = await postgresClient.query<{ id: string }>(
`SELECT id::text FROM configuration_items
WHERE rmm_device_audit_mac_address IS NOT NULL
AND LOWER(rmm_device_audit_mac_address) = LOWER($1)
AND (is_deleted IS NULL OR is_deleted = false)`,
[mac]
);
return res.rows.map((r) => ({
configuration_item_id: Number(r.id),
link_confidence: 'mac' as const,
}));
}
async function findByHostnameInCompany(
hostname: string,
companyId: number | null
): Promise<MatchCandidate[]> {
if (!companyId) return [];
const res = await postgresClient.query<{ id: string }>(
`SELECT id::text FROM configuration_items
WHERE company_id = $1
AND reference_title IS NOT NULL
AND LOWER(reference_title) = LOWER($2)
AND (is_deleted IS NULL OR is_deleted = false)`,
[companyId, hostname]
);
return res.rows.map((r) => ({
configuration_item_id: Number(r.id),
link_confidence: 'hostname_in_company' as const,
}));
}
async function applyLink(
rowId: number,
configurationItemId: number,
confidence: LinkConfidence
): Promise<void> {
await postgresClient.query(
`UPDATE device_external_ids
SET configuration_item_id = $2,
link_confidence = $3,
linked_at = NOW()
WHERE id = $1
AND configuration_item_id IS NULL`,
[rowId, configurationItemId, confidence]
);
// Propagate the new link into endpoint_audits / device_observations that
// were anchored only on the tool-side ID (e.g. an IT Glue config) at the
// time they were written. Without this they'd stay "unanchored" in the UI.
const linked = await postgresClient.query<{
source: string;
source_id: string;
}>(
`SELECT source, source_id FROM device_external_ids WHERE id = $1`,
[rowId]
);
const link = linked.rows[0];
if (!link) return;
if (link.source === 'itglue') {
await postgresClient.query(
`UPDATE endpoint_audits
SET configuration_item_id = $1
WHERE configuration_item_id IS NULL
AND itglue_configuration_id::text = $2`,
[configurationItemId, link.source_id]
);
}
}
async function recordConflict(
rowId: number,
candidates: MatchCandidate[]
): Promise<void> {
// Order candidates highest-confidence-first so the admin UI sees the best
// match at the top.
const ordered = [...candidates].sort(
(a, b) => LINK_CONFIDENCE_RANK[b.link_confidence] - LINK_CONFIDENCE_RANK[a.link_confidence]
);
const ciIds = ordered.map((c) => c.configuration_item_id);
const confidences = ordered.map((c) => c.link_confidence);
await postgresClient.query(
`INSERT INTO device_link_review (device_external_id, candidate_ci_ids, match_confidences)
VALUES ($1, $2::bigint[], $3::text[])
ON CONFLICT (device_external_id) WHERE resolved_at IS NULL
DO UPDATE SET candidate_ci_ids = EXCLUDED.candidate_ci_ids,
match_confidences = EXCLUDED.match_confidences,
detected_at = NOW()`,
[rowId, ciIds, confidences]
);
}
function pickBestCandidate(candidates: MatchCandidate[]): MatchCandidate | null {
if (candidates.length === 0) return null;
const ids = new Set(candidates.map((c) => c.configuration_item_id));
if (ids.size > 1) return null; // ambiguous — admin review
return candidates.reduce((best, c) =>
LINK_CONFIDENCE_RANK[c.link_confidence] > LINK_CONFIDENCE_RANK[best.link_confidence] ? c : best
);
}
/**
* Run one reconciliation pass over unlinked rows. Idempotent — safe to run
* repeatedly. Caller should schedule via sync-scheduler.
*/
export async function reconcileUnlinkedDevices(opts?: {
limit?: number;
dryRun?: boolean;
}): Promise<ReconcileResult> {
const limit = opts?.limit ?? 500;
const dryRun = opts?.dryRun ?? false;
const result: ReconcileResult = {
scanned: 0,
linked: 0,
conflicts: 0,
unmatched: 0,
byConfidence: {
canonical: 0,
exact_uid: 0,
exact_serial: 0,
mac: 0,
hostname_in_company: 0,
manual: 0,
},
};
const unlinked = await postgresClient.query<UnlinkedRow>(
`SELECT id, source, source_id, hostname, serial, mac, company_id
FROM device_external_ids
WHERE configuration_item_id IS NULL
ORDER BY last_seen_at DESC NULLS LAST
LIMIT $1`,
[limit]
);
for (const row of unlinked.rows) {
result.scanned += 1;
const candidates: MatchCandidate[] = [];
if (row.serial) candidates.push(...(await findBySerial(row.serial)));
if (row.mac) candidates.push(...(await findByMac(row.mac)));
if (row.hostname) candidates.push(...(await findByHostnameInCompany(row.hostname, row.company_id)));
if (candidates.length === 0) {
result.unmatched += 1;
continue;
}
const ids = new Set(candidates.map((c) => c.configuration_item_id));
if (ids.size > 1) {
result.conflicts += 1;
if (!dryRun) {
await recordConflict(row.id, candidates);
}
continue;
}
const best = pickBestCandidate(candidates);
if (!best) {
result.unmatched += 1;
continue;
}
if (!dryRun) {
await applyLink(row.id, best.configuration_item_id, best.link_confidence);
}
result.linked += 1;
result.byConfidence[best.link_confidence] += 1;
}
return result;
}