- 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>
201 lines
6.4 KiB
TypeScript
201 lines
6.4 KiB
TypeScript
/**
|
|
* POST /api/analyzer/itglue/applications/:id/apply
|
|
*
|
|
* Body: ApplyAssetSuggestionRequest = { auditId, fieldName, suggestedValue, sourceEvidence? }
|
|
*
|
|
* Flow:
|
|
* 1. Verify auth + itglue.write permission.
|
|
* 2. Read the asset's current traits (the source of truth for before_value).
|
|
* 3. Insert pending row in itglue_writes capturing before/after.
|
|
* 4. Call IT Glue PATCH /flexible_assets/:id (merged trait map).
|
|
* 5. On success: mark write committed, refresh the local mirror row, write
|
|
* a generic audit_log entry.
|
|
* 6. On failure: mark write failed, return 502.
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { requirePermission } from '@/lib/auth-utils';
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|
import { ApplyAssetSuggestionRequest } from '@/lib/types/analyzer';
|
|
import {
|
|
createPendingWrite,
|
|
fieldNameToTraitKey,
|
|
getAssetAuditById,
|
|
markWriteCommitted,
|
|
markWriteFailed,
|
|
} from '@/lib/services/analyzer/asset-audit/persistence';
|
|
import { insertUpdatedXref } from '@/lib/services/analyzer/asset-audit/xrefs';
|
|
import { getITGlueClient } from '@/lib/services/itglue-client';
|
|
import { getITGlueSyncService } from '@/lib/services/itglue-sync-service';
|
|
import { audit } from '@/lib/services/audit';
|
|
|
|
interface AssetRow {
|
|
id: string;
|
|
organization_name: string | null;
|
|
flexible_asset_type_id: string;
|
|
traits: Record<string, unknown>;
|
|
}
|
|
|
|
async function loadAssetRow(assetId: string): Promise<AssetRow | null> {
|
|
const res = await postgresClient.query<AssetRow>(
|
|
`SELECT id::text AS id,
|
|
organization_name,
|
|
flexible_asset_type_id::text AS flexible_asset_type_id,
|
|
traits
|
|
FROM itg_flexible_assets
|
|
WHERE id = $1
|
|
LIMIT 1`,
|
|
[assetId]
|
|
);
|
|
return res.rowCount === 0 ? null : res.rows[0];
|
|
}
|
|
|
|
export async function POST(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const { session, error } = await requirePermission('itglue', 'write');
|
|
if (error) return error;
|
|
|
|
const { id: assetId } = await params;
|
|
const body = await request.json().catch(() => ({}));
|
|
const parsed = ApplyAssetSuggestionRequest.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid request body', details: parsed.error.issues },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const { auditId, fieldName, suggestedValue, sourceEvidence } = parsed.data;
|
|
|
|
// Sanity-check: the audit must exist and reference this asset.
|
|
const auditRow = await getAssetAuditById(auditId);
|
|
if (!auditRow) {
|
|
return NextResponse.json({ error: 'Audit not found' }, { status: 404 });
|
|
}
|
|
if (auditRow.asset_id !== assetId) {
|
|
return NextResponse.json(
|
|
{ error: 'Audit does not reference this asset' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Refuse to write to credential-shaped fields. Belt for the prompt's
|
|
// braces; the prompt already tells the LLM not to suggest these, but
|
|
// re-block here so a malicious-looking payload can't sneak through.
|
|
const lowerField = fieldName.toLowerCase();
|
|
if (
|
|
/(password|secret|key|token|credential)/.test(lowerField)
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: 'Refusing to write to credential-shaped field' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Read current traits.
|
|
const asset = await loadAssetRow(assetId);
|
|
if (!asset) {
|
|
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
|
|
}
|
|
const traitKey = fieldNameToTraitKey(fieldName);
|
|
const beforeValue = asset.traits[traitKey] ?? null;
|
|
const userId =
|
|
(session?.user as { id: string; email?: string } | undefined)?.id ?? null;
|
|
const userEmail =
|
|
(session?.user as { id: string; email?: string } | undefined)?.email ??
|
|
undefined;
|
|
|
|
// Insert pending row first so we never write to IT Glue without an audit
|
|
// row in flight.
|
|
const writeRow = await createPendingWrite({
|
|
audit_id: auditId,
|
|
asset_type: 'flexible_asset',
|
|
asset_id: assetId,
|
|
field_name: fieldName,
|
|
before_value: beforeValue,
|
|
after_value: suggestedValue,
|
|
performed_by_user_id: userId,
|
|
source_evidence: sourceEvidence ?? null,
|
|
triggered_by_ticket_number: auditRow.triggered_by_ticket_number ?? null,
|
|
});
|
|
|
|
// Build merged trait map. IT Glue replaces the trait set on PATCH, so we
|
|
// must include unchanged traits.
|
|
const merged: Record<string, unknown> = {
|
|
...asset.traits,
|
|
[traitKey]: suggestedValue,
|
|
};
|
|
|
|
try {
|
|
const client = getITGlueClient();
|
|
const updated = await client.updateFlexibleAsset(assetId, merged);
|
|
await markWriteCommitted(writeRow.id, updated);
|
|
|
|
// Best-effort: refresh the mirror so subsequent reads see the new value
|
|
// without waiting for the next fullSync.
|
|
try {
|
|
await getITGlueSyncService().refreshFlexibleAssetById(assetId);
|
|
} catch (refreshErr) {
|
|
console.warn(
|
|
`[itglue-apply] mirror refresh failed for asset ${assetId}:`,
|
|
refreshErr instanceof Error ? refreshErr.message : refreshErr
|
|
);
|
|
}
|
|
|
|
// Generic admin-visible audit log row.
|
|
await audit.log({
|
|
userId: userId ?? undefined,
|
|
userEmail,
|
|
action: 'itglue.write',
|
|
resource: 'flexible_asset',
|
|
resourceId: assetId,
|
|
details: {
|
|
write_id: writeRow.id,
|
|
audit_id: auditId,
|
|
field_name: fieldName,
|
|
trait_key: traitKey,
|
|
before: beforeValue,
|
|
after: suggestedValue,
|
|
},
|
|
});
|
|
|
|
// Phase 4.1: cross-reference row when this write was triggered by a
|
|
// ticket-scoped audit. Best-effort.
|
|
if (auditRow.triggered_by_ticket_number) {
|
|
try {
|
|
await insertUpdatedXref({
|
|
ticketNumber: auditRow.triggered_by_ticket_number,
|
|
analysisId: auditRow.triggered_by_analysis_id ?? null,
|
|
assetType: 'flexible_asset',
|
|
assetId,
|
|
writeId: writeRow.id,
|
|
fieldName,
|
|
});
|
|
} catch (xrefErr) {
|
|
console.warn(
|
|
`[itglue-apply] xref insert failed for write ${writeRow.id}:`,
|
|
xrefErr instanceof Error ? xrefErr.message : xrefErr
|
|
);
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
writeId: writeRow.id,
|
|
status: 'committed',
|
|
asset: updated,
|
|
});
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
await markWriteFailed(writeRow.id, message);
|
|
return NextResponse.json(
|
|
{
|
|
writeId: writeRow.id,
|
|
status: 'failed',
|
|
error: 'IT Glue write failed',
|
|
message,
|
|
},
|
|
{ status: 502 }
|
|
);
|
|
}
|
|
}
|