Files
poshmanager/server/services/graphService.js
2026-06-25 12:05:53 -05:00

424 lines
17 KiB
JavaScript

import { isAllowedAuthorityHost, isAllowedGraphHost } from '../data/graphHosts.js';
function sanitizeBase(url = '') {
return String(url || '').replace(/\/+$/, '');
}
// Defense in depth: even though connection URLs are validated on write, re-check
// before every outbound request so stored/legacy rows can't trigger SSRF.
function assertAllowedEndpoints(connection) {
if (!isAllowedAuthorityHost(connection.authority_host)) {
throw new Error('Graph connection authority host is not an allowed Microsoft endpoint');
}
if (!isAllowedGraphHost(connection.graph_base_url)) {
throw new Error('Graph connection base URL is not an allowed Microsoft endpoint');
}
}
export async function getGraphToken(connection) {
assertAllowedEndpoints(connection);
const authority = sanitizeBase(connection.authority_host);
const graphBase = sanitizeBase(connection.graph_base_url);
const response = await fetch(`${authority}/${encodeURIComponent(connection.tenant_id)}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: connection.client_id,
client_secret: connection.clientSecret,
scope: `${graphBase}/.default`,
grant_type: 'client_credentials'
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error_description || payload.error || `Token request failed: ${response.status}`);
return payload.access_token;
}
export async function graphRequest(connection, path, options = {}) {
assertAllowedEndpoints(connection);
const token = options.token || await getGraphToken(connection);
const base = sanitizeBase(connection.graph_base_url);
const response = await fetch(`${base}${path}`, {
method: options.method || 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {})
},
body: options.body ? JSON.stringify(options.body) : undefined
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
const message = payload?.error?.message || payload?.error_description || `Graph request failed: ${response.status}`;
const error = new Error(message);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
export async function testGraphConnection(connection) {
const token = await getGraphToken(connection);
const payload = await graphRequest(connection, '/v1.0/deviceAppManagement?$select=id&$top=1', { token });
return { ok: true, message: `Connected. deviceAppManagement readable; sample keys: ${Object.keys(payload).join(', ')}` };
}
// Create or update the win32LobApp metadata object. Content (.intunewin bytes)
// upload is a separate Phase 2 concern; this writes the app definition only.
// mobileApps create/update lives on the beta endpoint for full win32 fidelity.
export async function upsertWin32App(connection, payload, existingAppId = '') {
const token = await getGraphToken(connection);
if (existingAppId) {
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(existingAppId)}`, {
token,
method: 'PATCH',
body: payload
});
return { id: existingAppId, updated: true };
}
const created = await graphRequest(connection, '/beta/deviceAppManagement/mobileApps', {
token,
method: 'POST',
body: payload
});
return { id: created.id, created: true, raw: created };
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForFileState(connection, token, appPath, cvId, fileId, target, { attempts = 30, delayMs = 2000 } = {}) {
for (let i = 0; i < attempts; i++) {
const file = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}`, { token });
const state = file.uploadState || '';
if (state === target) return file;
if (state.endsWith('Failed')) throw new Error(`Intune content upload failed in state "${state}"`);
await sleep(delayMs);
}
throw new Error(`Timed out waiting for Intune content state "${target}"`);
}
// Upload encrypted .intunewin content to Azure Storage in blocks, then commit.
// The SAS URI is returned by Graph (trusted); the block PUTs go straight to
// Azure Blob storage, outside the Graph allow-list.
async function azureBlockUpload(sasUri, content) {
const blockSize = 6 * 1024 * 1024;
const blockIds = [];
let index = 0;
for (let start = 0; start < content.length; start += blockSize) {
const chunk = content.subarray(start, start + blockSize);
const blockId = Buffer.from(String(index).padStart(8, '0')).toString('base64');
blockIds.push(blockId);
const res = await fetch(`${sasUri}&comp=block&blockid=${encodeURIComponent(blockId)}`, {
method: 'PUT',
headers: { 'x-ms-blob-type': 'BlockBlob' },
body: chunk
});
if (!res.ok) throw new Error(`Azure block upload failed (${res.status}) on block ${index}`);
index += 1;
}
const listXml = `<?xml version="1.0" encoding="utf-8"?><BlockList>${blockIds.map((b) => `<Latest>${b}</Latest>`).join('')}</BlockList>`;
const commit = await fetch(`${sasUri}&comp=blocklist`, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain; charset=UTF-8' },
body: listXml
});
if (!commit.ok) throw new Error(`Azure block-list commit failed (${commit.status})`);
}
// Full Win32 content commit flow (Phase 2). Requires a live tenant + Azure
// Storage; validated end to end only against a real tenant. `parsed` is the
// output of parseIntunewin().
export async function uploadWin32Content(connection, appId, parsed) {
const token = await getGraphToken(connection);
const appPath = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/microsoft.graph.win32LobApp`;
const version = await graphRequest(connection, `${appPath}/contentVersions`, { token, method: 'POST', body: {} });
const cvId = version.id;
const fileMeta = await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files`, {
token,
method: 'POST',
body: {
'@odata.type': '#microsoft.graph.mobileAppContentFile',
name: parsed.setupFile || 'IntunePackage.intunewin',
size: parsed.unencryptedContentSize,
sizeEncrypted: parsed.encryptedContentSize,
isDependency: false,
manifest: null
}
});
const fileId = fileMeta.id;
const ready = await waitForFileState(connection, token, appPath, cvId, fileId, 'azureStorageUriRequestSuccess');
await azureBlockUpload(ready.azureStorageUri, parsed.encryptedContent);
await graphRequest(connection, `${appPath}/contentVersions/${cvId}/files/${fileId}/commit`, {
token,
method: 'POST',
body: { fileEncryptionInfo: parsed.fileEncryptionInfo }
});
await waitForFileState(connection, token, appPath, cvId, fileId, 'commitFileSuccess');
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`, {
token,
method: 'PATCH',
body: { '@odata.type': '#microsoft.graph.win32LobApp', committedContentVersion: cvId }
});
return { contentVersionId: cvId, fileId, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize };
}
// Replace the app's assignments in one call. The /assign action is a full
// replacement, so callers send the complete desired assignment set.
export async function assignWin32App(connection, appId, mobileAppAssignments) {
const token = await getGraphToken(connection);
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/assign`, {
token,
method: 'POST',
body: { mobileAppAssignments }
});
return { ok: true, count: mobileAppAssignments.length };
}
// Replace the app's supersedence/dependency relationships. updateRelationships
// is also a full replacement of the app's outgoing relationship set.
export async function setWin32AppRelationships(connection, appId, relationships) {
const token = await getGraphToken(connection);
await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}/updateRelationships`, {
token,
method: 'POST',
body: { relationships }
});
return { ok: true, count: relationships.length };
}
// Live win32LobApp metadata + assignments + relationships, used by drift checks.
export async function getWin32AppState(connection, appId) {
const token = await getGraphToken(connection);
const base = `/beta/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}`;
const [app, assignments, relationships] = await Promise.all([
graphRequest(connection, base, { token }),
graphRequest(connection, `${base}/assignments`, { token }).then((r) => r.value || []).catch(() => []),
graphRequest(connection, `${base}/relationships`, { token }).then((r) => r.value || []).catch(() => [])
]);
return { app, assignments, relationships };
}
export async function getGraphMobileApp(connection, appId) {
const token = await getGraphToken(connection);
const version = connection.default_api_version || 'v1.0';
return graphRequest(
connection,
`/${version}/deviceAppManagement/mobileApps/${encodeURIComponent(appId)}?$select=id,displayName,publisher,displayVersion`,
{ token }
);
}
export async function listGraphMobileApps(connection, query = '') {
const token = await getGraphToken(connection);
const version = connection.default_api_version || 'v1.0';
const filter = query ? `&$filter=contains(displayName,'${String(query).replaceAll("'", "''")}')` : '';
const payload = await graphRequest(
connection,
`/${version}/deviceAppManagement/mobileApps?$select=id,displayName,publisher,createdDateTime,lastModifiedDateTime,isAssigned${filter}&$top=50`,
{ token }
);
return (payload.value || []).map((app) => ({
id: app.id,
displayName: app.displayName || app.name || app.id,
publisher: app.publisher || '',
createdDateTime: app.createdDateTime || '',
lastModifiedDateTime: app.lastModifiedDateTime || '',
isAssigned: Boolean(app.isAssigned)
}));
}
export async function syncIntuneDeploymentStatus(connection, graphAppId, mode = 'reports') {
const token = await getGraphToken(connection);
const rows = [];
const sources = [];
let overview = {};
if (mode === 'reports' || mode === 'both') {
const reportResult = await syncReports(connection, token, graphAppId);
rows.push(...reportResult.rows);
overview = reportResult.overview;
sources.push(...reportResult.sources);
}
if ((mode === 'legacy-status' || mode === 'both') && !rows.length) {
const legacyRows = await syncLegacyDeviceStatuses(connection, token, graphAppId);
rows.push(...legacyRows);
sources.push('legacy-deviceStatuses');
}
return {
rows,
overview,
sources,
summary: summarizeRows(rows, overview)
};
}
async function syncReports(connection, token, graphAppId) {
const rows = [];
const sources = [];
let overview = {};
try {
const appOverview = await graphRequest(connection, '/beta/deviceManagement/reports/getAppStatusOverviewReport', {
token,
method: 'POST',
body: { filter: `(ApplicationId eq '${graphAppId}')` }
});
overview = reportRows(appOverview)[0] || {};
sources.push('getAppStatusOverviewReport');
} catch (error) {
overview = { warning: `Overview report unavailable: ${error.message}` };
}
try {
const deviceReport = await graphRequest(connection, '/beta/deviceManagement/reports/getDeviceInstallStatusReport', {
token,
method: 'POST',
body: {
select: [
'DeviceId',
'DeviceName',
'UserPrincipalName',
'Platform',
'Status',
'ErrorCode',
'AppInstallState',
'InstallState',
'InstallStateDetail',
'LastModifiedDateTime',
'ApplicationId'
],
filter: `(ApplicationId eq '${graphAppId}')`,
skip: 0,
top: 50000,
orderBy: []
}
});
rows.push(...reportRows(deviceReport).map((row) => normalizeReportStatus(row, 'device')));
sources.push('getDeviceInstallStatusReport');
} catch (error) {
rows.push(failureRow('device', 'report-unavailable', error.message, { source: 'getDeviceInstallStatusReport' }));
}
try {
const userReport = await graphRequest(connection, '/beta/deviceManagement/reports/getUserInstallStatusReport', {
token,
method: 'POST',
body: {
select: ['UserId', 'UserName', 'UserPrincipalName', 'Status', 'InstalledDeviceCount', 'FailedDeviceCount', 'ApplicationId'],
filter: `(ApplicationId eq '${graphAppId}')`,
skip: 0,
top: 50000,
orderBy: []
}
});
rows.push(...reportRows(userReport).map((row) => normalizeReportStatus(row, 'user')));
sources.push('getUserInstallStatusReport');
} catch (error) {
rows.push(failureRow('user', 'report-unavailable', error.message, { source: 'getUserInstallStatusReport' }));
}
return { rows, overview, sources };
}
async function syncLegacyDeviceStatuses(connection, token, graphAppId) {
const payload = await graphRequest(connection, `/beta/deviceAppManagement/mobileApps/${graphAppId}/deviceStatuses`, { token });
return (payload.value || []).map((row) => ({
scope: 'device',
principalId: row.id || '',
principalName: row.deviceName || row.userName || '',
deviceId: row.deviceId || '',
deviceName: row.deviceName || '',
userId: row.userId || '',
userPrincipalName: row.userPrincipalName || row.userName || '',
installState: normalizeState(row.installState || row.mobileAppInstallStatusValue || row.status),
installStateDetail: row.installStateDetail || '',
errorCode: row.errorCode,
errorDescription: row.errorDescription || failureCause(row.errorCode, row.installStateDetail),
lastSyncDateTime: row.lastSyncDateTime || '',
raw: row
}));
}
function reportRows(report) {
const schema = report.Schema || report.schema || [];
const values = report.Values || report.values || report.value || [];
if (!Array.isArray(values)) return [];
if (!schema.length) return values;
return values.map((entry) => {
if (!Array.isArray(entry)) return entry;
return Object.fromEntries(schema.map((column, index) => [column.Column || column.column || column.Name || column.name, entry[index]]));
});
}
function normalizeReportStatus(row, scope) {
const installState = normalizeState(row.Status || row.InstallState || row.AppInstallState || row.State || row.InstallStatus);
const errorCode = row.ErrorCode ?? row.Error ?? row.HexErrorCode ?? '';
return {
scope,
principalId: scope === 'device' ? row.DeviceId || row.ManagedDeviceId || '' : row.UserId || '',
principalName: scope === 'device' ? row.DeviceName || '' : row.UserName || row.UserPrincipalName || '',
deviceId: row.DeviceId || row.ManagedDeviceId || '',
deviceName: row.DeviceName || '',
userId: row.UserId || '',
userPrincipalName: row.UserPrincipalName || row.UserName || '',
installState,
installStateDetail: row.InstallStateDetail || row.StatusDetails || row.StateDetails || '',
errorCode,
errorDescription: row.ErrorDescription || row.ErrorDetails || failureCause(errorCode, row.InstallStateDetail),
lastSyncDateTime: row.LastModifiedDateTime || row.LastSyncDateTime || row.LastReportedDateTime || '',
raw: row
};
}
function normalizeState(value = '') {
const text = String(value || '').toLowerCase();
if (text.includes('fail') || text === 'error') return 'failed';
if (text.includes('install') || text === 'success' || text === 'installed') return 'installed';
if (text.includes('pending') || text.includes('progress')) return 'pending';
if (text.includes('notapplicable') || text.includes('not applicable')) return 'notApplicable';
if (text.includes('notinstalled') || text.includes('not installed')) return 'notInstalled';
return text || 'unknown';
}
function failureRow(scope, code, message, raw) {
return {
scope,
installState: 'failed',
installStateDetail: code,
errorCode: code,
errorDescription: message,
raw
};
}
function failureCause(errorCode, detail = '') {
const code = String(errorCode || '').toLowerCase();
const known = {
'0x87d1041c': 'Intune did not detect the app after installation. Check detection rules, install command, and app install timing.',
'0x87d13b9f': 'App installation failed. Review Intune Management Extension logs and PSADT logs.',
'1602': 'User deferred or cancelled installation. Confirm return code is mapped as retry and DeferRunInterval is configured when needed.',
'3010': 'Soft reboot required. Confirm Intune return code behavior.',
'1703': 'Legacy soft reboot mapping. Confirm this is intentional for the package.'
};
return known[code] || detail || '';
}
function summarizeRows(rows, overview = {}) {
const counts = { total: rows.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0 };
for (const row of rows) {
const state = row.installState || 'unknown';
if (counts[state] == null) counts.unknown += 1;
else counts[state] += 1;
}
return { counts, overview };
}