wulf-pulse/app/api/analyzer/itglue/applications/[id]/apply/route.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

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 }
);
}
}