wulf-pulse/lib/services/itglue-client.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

571 lines
19 KiB
TypeScript

/**
* IT Glue API Client
* Auth: x-api-key header
* Base: https://api.itglue.com
* Format: application/vnd.api+json (JSON:API)
*/
export interface ITGlueConfig {
apiKey: string;
baseUrl?: string;
}
export interface ITGlueOrganization {
id: string;
name: string;
shortName: string | null;
organizationTypeId: number | null;
organizationTypeName: string | null;
organizationStatusId: number | null;
organizationStatusName: string | null;
psaIntegration: string | null;
syncActive: boolean;
primary: boolean;
createdAt: string;
updatedAt: string;
}
export interface ITGlueFlexibleAsset {
id: string;
organizationId: number;
organizationName: string;
flexibleAssetTypeId: number;
flexibleAssetTypeName: string;
name: string;
traits: Record<string, any>;
createdAt: string;
updatedAt: string;
}
export interface ITGlueConfiguration {
id: string;
organizationId: number;
organizationName: string;
name: string;
hostname: string | null;
primaryIp: string | null;
macAddress: string | null;
serialNumber: string | null;
assetTag: string | null;
configurationTypeId: number | null;
configurationTypeName: string | null;
configurationStatusId: number | null;
configurationStatusName: string | null;
manufacturerId: number | null;
manufacturerName: string | null;
modelId: number | null;
modelName: string | null;
operatingSystemId: number | null;
operatingSystemName: string | null;
notes: string | null;
purchasedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface ITGluePassword {
id: string;
organizationId: number;
organizationName: string;
name: string;
username: string | null;
password: string | null;
url: string | null;
notes: string | null;
passwordCategoryId: number | null;
passwordCategoryName: string | null;
createdAt: string;
updatedAt: string;
}
export interface ITGlueContact {
id: string;
organizationId: number;
organizationName: string;
firstName: string | null;
lastName: string | null;
title: string | null;
contactTypeId: number | null;
contactTypeName: string | null;
emails: { value: string; primary: boolean; labelName: string }[];
phones: { value: string; extension: string | null; primary: boolean; labelName: string }[];
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface ITGlueFlexibleAssetType {
id: string;
name: string;
description: string | null;
icon: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface ITGluePaginatedResponse<T> {
data: T[];
meta: {
currentPage: number;
nextPage: number | null;
prevPage: number | null;
totalPages: number;
totalCount: number;
};
}
export class ITGlueClient {
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly DEFAULT_PAGE_SIZE = 50;
constructor(config: ITGlueConfig) {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl || 'https://api.itglue.com';
}
private get headers(): Record<string, string> {
return {
'x-api-key': this.apiKey,
'Content-Type': 'application/vnd.api+json',
};
}
private async request<T>(path: string, params: Record<string, string | number> = {}): Promise<T> {
const url = new URL(`${this.baseUrl}${path}`);
for (const [k, v] of Object.entries(params)) {
url.searchParams.set(k, String(v));
}
const res = await fetch(url.toString(), { headers: this.headers });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`IT Glue API ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
}
return res.json();
}
/**
* Internal PATCH helper. Used by the audit feature to update flexible assets.
* Body should be a JSON:API resource object (e.g. `{ data: { type, attributes } }`).
* Returns the parsed JSON response (typically `{ data: {...} }`).
*/
private async patch<T = unknown>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'PATCH',
headers: this.headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(
`IT Glue PATCH ${path}${res.status} ${res.statusText}: ${text.slice(0, 500)}`
);
}
return res.json() as Promise<T>;
}
private async fetchAllPages<T>(
path: string,
params: Record<string, string | number> = {},
mapper: (item: any) => T
): Promise<T[]> {
const results: T[] = [];
let page = 1;
let totalPages = 1;
do {
const data: any = await this.request(path, {
...params,
'page[size]': this.DEFAULT_PAGE_SIZE,
'page[number]': page,
});
results.push(...(data.data || []).map(mapper));
totalPages = data.meta?.['total-pages'] ?? 1;
page++;
} while (page <= totalPages);
return results;
}
/** Returns raw JSON:API data array for a single page (used by sync service) */
async getRaw(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
const data: any = await this.request(path, params);
return data.data || [];
}
/**
* Returns the raw JSON:API resource for a single-resource endpoint
* (`{ data: {...} }`). Used for per-record refresh after writes so callers
* can read the un-mapped attributes (created-at, updated-at, etc.) for a
* faithful upsert.
*/
async getRawSingle(
path: string,
params: Record<string, string | number> = {}
): Promise<{ id: string; type: string; attributes: Record<string, unknown> } | null> {
const data: any = await this.request(path, params);
return data.data ?? null;
}
/** Returns all raw JSON:API data items across all pages (used by sync service) */
async getRawAllPages(path: string, params: Record<string, string | number> = {}): Promise<any[]> {
const results: any[] = [];
let page = 1;
let totalPages = 1;
do {
const data: any = await this.request(path, {
...params,
'page[size]': this.DEFAULT_PAGE_SIZE,
'page[number]': page,
});
results.push(...(data.data || []));
totalPages = data.meta?.['total-pages'] ?? 1;
page++;
} while (page <= totalPages);
return results;
}
// ─── Organizations ────────────────────────────────────────────────────────
private mapOrg(item: any): ITGlueOrganization {
const a = item.attributes;
return {
id: item.id,
name: a['name'],
shortName: a['short-name'] ?? null,
organizationTypeId: a['organization-type-id'] ?? null,
organizationTypeName: a['organization-type-name'] ?? null,
organizationStatusId: a['organization-status-id'] ?? null,
organizationStatusName: a['organization-status-name'] ?? null,
psaIntegration: a['psa-integration'] ?? null,
syncActive: a['sync-active'] ?? false,
primary: a['primary'] ?? false,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getOrganizations(filter?: { name?: string; organizationTypeId?: number }): Promise<ITGlueOrganization[]> {
const params: Record<string, string | number> = {};
if (filter?.name) params['filter[name]'] = filter.name;
if (filter?.organizationTypeId) params['filter[organization-type-id]'] = filter.organizationTypeId;
return this.fetchAllPages('/organizations', params, this.mapOrg);
}
async getOrganization(id: string | number): Promise<ITGlueOrganization> {
const data: any = await this.request(`/organizations/${id}`);
return this.mapOrg(data.data);
}
async findOrganizationByName(name: string): Promise<ITGlueOrganization | null> {
const data: any = await this.request('/organizations', {
'filter[name]': name,
'page[size]': 5,
});
if (!data.data?.length) return null;
return this.mapOrg(data.data[0]);
}
// ─── Flexible Assets ──────────────────────────────────────────────────────
private mapFlexibleAsset(item: any): ITGlueFlexibleAsset {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
flexibleAssetTypeId: a['flexible-asset-type-id'],
flexibleAssetTypeName: a['flexible-asset-type-name'],
name: a['name'],
traits: a['traits'] ?? {},
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getFlexibleAssets(params: {
organizationId?: number | string;
flexibleAssetTypeId?: number | string;
filter?: Record<string, string>;
}): Promise<ITGlueFlexibleAsset[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.flexibleAssetTypeId) p['filter[flexible-asset-type-id]'] = params.flexibleAssetTypeId;
if (params.filter) {
for (const [k, v] of Object.entries(params.filter)) {
p[`filter[${k}]`] = v;
}
}
return this.fetchAllPages('/flexible_assets', p, this.mapFlexibleAsset);
}
async getFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
const data: any = await this.request(`/flexible_assets/${id}`);
return this.mapFlexibleAsset(data.data);
}
/**
* Update a flexible asset's traits. Sends PATCH /flexible_assets/:id with a
* JSON:API body. `traits` is the merged trait map (IT Glue replaces the
* trait set, so callers must include unchanged traits to preserve them; the
* audit pipeline always reads then merges).
*
* Returns the updated asset as IT Glue returns it.
*/
async updateFlexibleAsset(
id: string | number,
traits: Record<string, unknown>
): Promise<ITGlueFlexibleAsset> {
const body = {
data: {
type: 'flexible_assets',
id: String(id),
attributes: { traits },
},
};
const res = await this.patch<{ data: any }>(`/flexible_assets/${id}`, body);
return this.mapFlexibleAsset(res.data);
}
/**
* Re-fetch a single flexible asset from IT Glue. Thin wrapper around
* getFlexibleAsset; exists so callers naming "refresh" intent stays clear
* separate from "read once".
*/
async refreshFlexibleAsset(id: string | number): Promise<ITGlueFlexibleAsset> {
return this.getFlexibleAsset(id);
}
/**
* Update a configuration's editable attributes. Sends PATCH /configurations/:id
* with a JSON:API body. Configurations have a flat attribute set (no traits
* blob), so callers pass the partial map of fields to change — IT Glue
* merges into the existing record.
*/
async updateConfiguration(
id: string | number,
attributes: Record<string, unknown>
): Promise<ITGlueConfiguration> {
const body = {
data: {
type: 'configurations',
id: String(id),
attributes,
},
};
const res = await this.patch<{ data: any }>(`/configurations/${id}`, body);
return this.mapConfiguration(res.data);
}
async refreshConfiguration(id: string | number): Promise<ITGlueConfiguration> {
return this.getConfiguration(id);
}
async getFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
return this.fetchAllPages('/flexible_asset_types', {}, (item: any) => ({
id: item.id,
name: item.attributes['name'],
description: item.attributes['description'] ?? null,
icon: item.attributes['icon'] ?? null,
enabled: item.attributes['enabled'] ?? true,
createdAt: item.attributes['created-at'],
updatedAt: item.attributes['updated-at'],
}));
}
/**
* Cached, per-instance fetch of enabled flexible asset type ids. IT Glue's
* /flexible_assets endpoint requires a flexibleAssetTypeId filter (otherwise
* 422). This caches the type list so callers don't pay the lookup on every
* call. Cache lives for the life of the process — types rarely change.
*/
private flexibleAssetTypesCache: Promise<ITGlueFlexibleAssetType[]> | null = null;
private async cachedFlexibleAssetTypes(): Promise<ITGlueFlexibleAssetType[]> {
if (!this.flexibleAssetTypesCache) {
this.flexibleAssetTypesCache = this.getFlexibleAssetTypes().catch((err) => {
// Re-throw next call so a transient failure isn't sticky.
this.flexibleAssetTypesCache = null;
throw err;
});
}
return this.flexibleAssetTypesCache;
}
/**
* Get every flexible asset for a given organization across all enabled types.
* IT Glue's /flexible_assets endpoint requires a per-type filter (the
* `getFlexibleAssets` raw call returns 422 otherwise), so this helper
* enumerates types and fans out per-type requests in parallel. Per-type
* failures are tolerated so a single bad type doesn't poison the whole org.
*/
async getFlexibleAssetsForOrganization(
organizationId: number | string
): Promise<ITGlueFlexibleAsset[]> {
const types = await this.cachedFlexibleAssetTypes();
const enabled = types.filter((t) => t.enabled);
const results = await Promise.allSettled(
enabled.map((t) =>
this.getFlexibleAssets({
organizationId,
flexibleAssetTypeId: t.id,
})
)
);
const out: ITGlueFlexibleAsset[] = [];
for (const r of results) {
if (r.status === 'fulfilled') out.push(...r.value);
// Tolerate per-type failures — common for permission-restricted types.
}
return out;
}
// ─── Configurations ───────────────────────────────────────────────────────
private mapConfiguration(item: any): ITGlueConfiguration {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
name: a['name'],
hostname: a['hostname'] ?? null,
primaryIp: a['primary-ip'] ?? null,
macAddress: a['mac-address'] ?? null,
serialNumber: a['serial-number'] ?? null,
assetTag: a['asset-tag'] ?? null,
configurationTypeId: a['configuration-type-id'] ?? null,
configurationTypeName: a['configuration-type-name'] ?? null,
configurationStatusId: a['configuration-status-id'] ?? null,
configurationStatusName: a['configuration-status-name'] ?? null,
manufacturerId: a['manufacturer-id'] ?? null,
manufacturerName: a['manufacturer-name'] ?? null,
modelId: a['model-id'] ?? null,
modelName: a['model-name'] ?? null,
operatingSystemId: a['operating-system-id'] ?? null,
operatingSystemName: a['operating-system-name'] ?? null,
notes: a['notes'] ?? null,
purchasedAt: a['purchased-at'] ?? null,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getConfigurations(params: {
organizationId?: number | string;
name?: string;
hostname?: string;
serialNumber?: string;
} = {}): Promise<ITGlueConfiguration[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.name) p['filter[name]'] = params.name;
if (params.hostname) p['filter[hostname]'] = params.hostname;
if (params.serialNumber) p['filter[serial-number]'] = params.serialNumber;
return this.fetchAllPages('/configurations', p, this.mapConfiguration);
}
async getConfiguration(id: string | number): Promise<ITGlueConfiguration> {
const data: any = await this.request(`/configurations/${id}`);
return this.mapConfiguration(data.data);
}
// ─── Passwords ────────────────────────────────────────────────────────────
private mapPassword(item: any): ITGluePassword {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
name: a['name'],
username: a['username'] ?? null,
password: a['password'] ?? null,
url: a['url'] ?? null,
notes: a['notes'] ?? null,
passwordCategoryId: a['password-category-id'] ?? null,
passwordCategoryName: a['password-category-name'] ?? null,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getPasswords(params: {
organizationId?: number | string;
name?: string;
} = {}): Promise<ITGluePassword[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.name) p['filter[name]'] = params.name;
return this.fetchAllPages('/passwords', p, this.mapPassword);
}
// ─── Contacts ─────────────────────────────────────────────────────────────
private mapContact(item: any): ITGlueContact {
const a = item.attributes;
return {
id: item.id,
organizationId: a['organization-id'],
organizationName: a['organization-name'],
firstName: a['first-name'] ?? null,
lastName: a['last-name'] ?? null,
title: a['title'] ?? null,
contactTypeId: a['contact-type-id'] ?? null,
contactTypeName: a['contact-type-name'] ?? null,
emails: (a['contact-emails'] ?? []).map((e: any) => ({
value: e.value,
primary: e.primary,
labelName: e['label-name'],
})),
phones: (a['contact-phones'] ?? []).map((p: any) => ({
value: p.value,
extension: p.extension ?? null,
primary: p.primary,
labelName: p['label-name'],
})),
notes: a['notes'] ?? null,
createdAt: a['created-at'],
updatedAt: a['updated-at'],
};
}
async getContacts(params: {
organizationId?: number | string;
name?: string;
} = {}): Promise<ITGlueContact[]> {
const p: Record<string, string | number> = {};
if (params.organizationId) p['filter[organization-id]'] = params.organizationId;
if (params.name) p['filter[name]'] = params.name;
return this.fetchAllPages('/contacts', p, this.mapContact);
}
// ─── Utility ──────────────────────────────────────────────────────────────
async testConnection(): Promise<{ ok: boolean; organizationCount: number; accountName: string }> {
const data: any = await this.request('/organizations', { 'page[size]': 1 });
return {
ok: true,
organizationCount: data.meta?.['total-count'] ?? 0,
accountName: data.data?.[0]?.attributes?.name ?? 'Unknown',
};
}
}
// Singleton
let _client: ITGlueClient | null = null;
export function getITGlueClient(): ITGlueClient {
if (!_client) {
const apiKey = process.env.ITGLUE_API_KEY;
if (!apiKey) throw new Error('ITGLUE_API_KEY environment variable is not set');
_client = new ITGlueClient({ apiKey });
}
return _client;
}
export function isITGlueConfigured(): boolean {
return !!process.env.ITGLUE_API_KEY;
}