Initial
This commit is contained in:
268
server/services/intunePublishService.js
Normal file
268
server/services/intunePublishService.js
Normal file
@@ -0,0 +1,268 @@
|
||||
// Pure mapping from a POSHManager Intune deployment plan (+ optional PSADT
|
||||
// profile) to the Microsoft Graph `win32LobApp` resource shape and its
|
||||
// assignment/relationship bodies.
|
||||
//
|
||||
// These functions perform no I/O so they can be unit-tested without a tenant.
|
||||
// The live HTTP calls live in graphService; the publish controller composes the
|
||||
// two. Field shapes follow the Graph beta `win32LobApp` documentation.
|
||||
|
||||
const RESTART_BEHAVIOR = {
|
||||
'return-code': 'basedOnReturnCode',
|
||||
suppress: 'suppress',
|
||||
intune: 'force'
|
||||
};
|
||||
|
||||
const RETURN_CODE_TYPES = new Set(['success', 'softReboot', 'hardReboot', 'retry', 'failed']);
|
||||
|
||||
// Map a human OS string ("Windows 10 22H2", "Windows 11 23H2") to the Graph
|
||||
// windowsMinimumOperatingSystem boolean flag. Defaults to a safe broad floor.
|
||||
function minimumSupportedOperatingSystem(minOs = '') {
|
||||
const text = String(minOs).toLowerCase();
|
||||
const flags = {
|
||||
v10_1607: false, v10_1703: false, v10_1709: false, v10_1803: false,
|
||||
v10_1809: false, v10_1903: false, v10_1909: false, v10_2004: false,
|
||||
v10_20H2: false, v10_21H1: false, v10_21H2: false, v10_22H2: false,
|
||||
v11_21H2: false, v11_22H2: false, v11_23H2: false, v11_24H2: false
|
||||
};
|
||||
const map = [
|
||||
['11 24h2', 'v11_24H2'], ['11 23h2', 'v11_23H2'], ['11 22h2', 'v11_22H2'], ['11 21h2', 'v11_21H2'],
|
||||
['22h2', 'v10_22H2'], ['21h2', 'v10_21H2'], ['21h1', 'v10_21H1'], ['20h2', 'v10_20H2'],
|
||||
['2004', 'v10_2004'], ['1909', 'v10_1909'], ['1903', 'v10_1903'], ['1809', 'v10_1809'],
|
||||
['1803', 'v10_1803'], ['1709', 'v10_1709'], ['1703', 'v10_1703'], ['1607', 'v10_1607']
|
||||
];
|
||||
const match = map.find(([needle]) => text.includes(needle));
|
||||
flags[match ? match[1] : 'v10_1809'] = true;
|
||||
return flags;
|
||||
}
|
||||
|
||||
function applicableArchitectures(architecture = 'x64') {
|
||||
if (architecture === 'both') return 'x64,x86';
|
||||
if (architecture === 'x86') return 'x86';
|
||||
return 'x64';
|
||||
}
|
||||
|
||||
function mapReturnCodes(returnCodes = []) {
|
||||
const mapped = (returnCodes || []).map((entry) => ({
|
||||
returnCode: Number(entry.code),
|
||||
type: RETURN_CODE_TYPES.has(entry.type) ? entry.type : 'failed'
|
||||
})).filter((entry) => Number.isFinite(entry.returnCode));
|
||||
// Intune requires at least the canonical 0/1707/3010/1641 floor; ensure 0=success exists.
|
||||
if (!mapped.some((entry) => entry.returnCode === 0)) mapped.unshift({ returnCode: 0, type: 'success' });
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// Detection rule mapping. Our model stores a single `detectionRule` string plus
|
||||
// a `detectionType`, so structured types (file/registry) that need more fields
|
||||
// are emitted best-effort and a warning is returned for operator review.
|
||||
function mapDetectionRules(deployment, warnings) {
|
||||
const rule = String(deployment.detectionRule || '').trim();
|
||||
switch (deployment.detectionType) {
|
||||
case 'msi-product-code':
|
||||
if (!rule) warnings.push('MSI detection selected but no product code provided.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppProductCodeDetection',
|
||||
productCode: rule,
|
||||
productVersionOperator: 'notConfigured',
|
||||
productVersion: null
|
||||
}];
|
||||
case 'custom-script':
|
||||
if (!rule) warnings.push('Custom-script detection selected but the script body is empty.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
|
||||
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
|
||||
enforceSignatureCheck: false,
|
||||
runAs32Bit: false
|
||||
}];
|
||||
case 'file-version':
|
||||
warnings.push('File-version detection requires structured path/file/version fields; emitted a best-effort rule for review.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppFileSystemDetection',
|
||||
path: rule || 'C:\\Program Files',
|
||||
fileOrFolderName: '',
|
||||
check32BitOn64System: false,
|
||||
detectionType: 'exists',
|
||||
operator: 'notConfigured',
|
||||
detectionValue: null
|
||||
}];
|
||||
case 'registry':
|
||||
warnings.push('Registry detection requires structured key/value fields; emitted a best-effort rule for review.');
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppRegistryDetection',
|
||||
keyPath: rule || 'HKEY_LOCAL_MACHINE\\SOFTWARE',
|
||||
valueName: '',
|
||||
check32BitOn64System: false,
|
||||
detectionType: 'exists',
|
||||
operator: 'notConfigured',
|
||||
detectionValue: null
|
||||
}];
|
||||
default:
|
||||
warnings.push(`Unknown detection type "${deployment.detectionType}"; defaulted to script detection.`);
|
||||
return [{
|
||||
'@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection',
|
||||
scriptContent: Buffer.from(rule, 'utf8').toString('base64'),
|
||||
enforceSignatureCheck: false,
|
||||
runAs32Bit: false
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWin32LobAppPayload(deployment, profile = null) {
|
||||
const warnings = [];
|
||||
if (!deployment) throw new Error('A deployment is required to build a win32LobApp payload');
|
||||
const requirements = deployment.requirements || {};
|
||||
const displayName = profile?.appName
|
||||
? `${profile.appVendor ? `${profile.appVendor} ` : ''}${profile.appName}${profile.appVersion ? ` ${profile.appVersion}` : ''}`.trim()
|
||||
: deployment.name;
|
||||
const fileName = deployment.intunewinFile || `${(deployment.name || 'package').replace(/[^A-Za-z0-9._-]+/g, '-')}.intunewin`;
|
||||
|
||||
if (!deployment.installCommand) warnings.push('Install command is empty.');
|
||||
if (!deployment.uninstallCommand) warnings.push('Uninstall command is empty.');
|
||||
if (!deployment.intunewinFile) warnings.push('No .intunewin file recorded; content must be uploaded before the app is installable (Phase 2).');
|
||||
|
||||
const payload = {
|
||||
'@odata.type': '#microsoft.graph.win32LobApp',
|
||||
displayName,
|
||||
description: deployment.notes || displayName,
|
||||
publisher: profile?.appVendor || 'POSHManager',
|
||||
fileName,
|
||||
setupFilePath: fileName,
|
||||
installCommandLine: deployment.installCommand || '',
|
||||
uninstallCommandLine: deployment.uninstallCommand || '',
|
||||
applicableArchitectures: applicableArchitectures(requirements.architecture),
|
||||
allowAvailableUninstall: true,
|
||||
minimumFreeDiskSpaceInMB: Number(requirements.diskSpaceMb) > 0 ? Number(requirements.diskSpaceMb) : null,
|
||||
minimumSupportedOperatingSystem: minimumSupportedOperatingSystem(requirements.minOs),
|
||||
installExperience: {
|
||||
'@odata.type': 'microsoft.graph.win32LobAppInstallExperience',
|
||||
runAsAccount: deployment.installBehavior === 'user' ? 'user' : 'system',
|
||||
deviceRestartBehavior: RESTART_BEHAVIOR[deployment.restartBehavior] || 'basedOnReturnCode'
|
||||
},
|
||||
returnCodes: mapReturnCodes(deployment.returnCodes),
|
||||
detectionRules: mapDetectionRules(deployment, warnings),
|
||||
requirementRules: [],
|
||||
msiInformation: null,
|
||||
runAs32Bit: Boolean(requirements.runAs32Bit)
|
||||
};
|
||||
|
||||
return { payload, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 3 — assignments / filters / rings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ASSIGNMENT_INTENTS = new Set(['available', 'required', 'uninstall']);
|
||||
|
||||
function assignmentTarget(assignment, warnings) {
|
||||
const filter = assignment.filterId
|
||||
? {
|
||||
deviceAndAppManagementAssignmentFilterId: assignment.filterId,
|
||||
deviceAndAppManagementAssignmentFilterType: assignment.filterType && assignment.filterType !== 'none' ? assignment.filterType : 'include'
|
||||
}
|
||||
: { deviceAndAppManagementAssignmentFilterId: null, deviceAndAppManagementAssignmentFilterType: 'none' };
|
||||
|
||||
if (assignment.groupMode === 'allDevices') {
|
||||
return { '@odata.type': '#microsoft.graph.allDevicesAssignmentTarget', ...filter };
|
||||
}
|
||||
if (assignment.groupMode === 'allUsers') {
|
||||
return { '@odata.type': '#microsoft.graph.allLicensedUsersAssignmentTarget', ...filter };
|
||||
}
|
||||
if (!assignment.groupId) {
|
||||
warnings.push(`Assignment "${assignment.ring}" has no Entra group id and was skipped. Resolve the group to its object id first.`);
|
||||
return null;
|
||||
}
|
||||
const isExclusion = assignment.intent === 'exclude';
|
||||
return {
|
||||
'@odata.type': isExclusion
|
||||
? '#microsoft.graph.exclusionGroupAssignmentTarget'
|
||||
: '#microsoft.graph.groupAssignmentTarget',
|
||||
groupId: assignment.groupId,
|
||||
...filter
|
||||
};
|
||||
}
|
||||
|
||||
// Build the `mobileAppAssignments` array for the /assign action. Exclusions map
|
||||
// to exclusionGroupAssignmentTarget (Graph still requires a real intent, so we
|
||||
// exclude from "required"). Group assignments without a resolved object id are
|
||||
// skipped with a warning rather than producing an invalid request.
|
||||
export function buildAssignmentsPayload(deployment) {
|
||||
const warnings = [];
|
||||
const assignments = [];
|
||||
for (const assignment of deployment.assignments || []) {
|
||||
const target = assignmentTarget(assignment, warnings);
|
||||
if (!target) continue;
|
||||
const intent = assignment.intent === 'exclude'
|
||||
? 'required'
|
||||
: (ASSIGNMENT_INTENTS.has(assignment.intent) ? assignment.intent : 'required');
|
||||
assignments.push({
|
||||
'@odata.type': '#microsoft.graph.mobileAppAssignment',
|
||||
intent,
|
||||
target,
|
||||
settings: {
|
||||
'@odata.type': '#microsoft.graph.win32LobAppAssignmentSettings',
|
||||
notifications: 'showAll',
|
||||
restartSettings: null,
|
||||
installTimeSettings: null,
|
||||
deliveryOptimizationPriority: 'notConfigured',
|
||||
autoUpdateSettings: assignment.autoUpdate
|
||||
? { '@odata.type': '#microsoft.graph.win32LobAppAutoUpdateSettings', autoUpdateSupersededApps: 'enabled' }
|
||||
: null
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!assignments.length) warnings.push('No assignable targets were produced; nothing would be assigned.');
|
||||
return { assignments, warnings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4 — supersedence & dependency relationships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Intune limits a supersedence graph to 11 nodes total (this app + superseded +
|
||||
// their related apps). We can only see this app's direct edges, so we enforce a
|
||||
// local ceiling of 10 supersedence targets and flag that the full graph limit is
|
||||
// tenant-wide. Self-references and duplicates are rejected as cycles.
|
||||
export const MAX_SUPERSEDENCE_TARGETS = 10;
|
||||
|
||||
export function validateRelationships(relationships = [], selfAppId = '') {
|
||||
const errors = [];
|
||||
const seen = new Set();
|
||||
let supersedenceCount = 0;
|
||||
for (const rel of relationships) {
|
||||
const target = rel.targetAppId;
|
||||
if (!target) { errors.push('A relationship is missing targetAppId.'); continue; }
|
||||
if (selfAppId && target === selfAppId) errors.push(`An app cannot have a ${rel.kind} relationship with itself (${target}).`);
|
||||
const key = `${rel.kind}:${target}`;
|
||||
if (seen.has(key)) errors.push(`Duplicate ${rel.kind} relationship to ${target}.`);
|
||||
seen.add(key);
|
||||
if (rel.kind === 'supersedence') supersedenceCount += 1;
|
||||
}
|
||||
if (supersedenceCount > MAX_SUPERSEDENCE_TARGETS) {
|
||||
errors.push(`Too many supersedence targets (${supersedenceCount}); Intune allows at most ${MAX_SUPERSEDENCE_TARGETS} direct targets (11-node graph limit).`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function buildRelationshipsPayload(relationships = [], selfAppId = '') {
|
||||
const errors = validateRelationships(relationships, selfAppId);
|
||||
if (errors.length) {
|
||||
const error = new Error(errors.join(' '));
|
||||
error.validation = errors;
|
||||
throw error;
|
||||
}
|
||||
const mapped = relationships.map((rel) => {
|
||||
if (rel.kind === 'dependency') {
|
||||
return {
|
||||
'@odata.type': '#microsoft.graph.mobileAppDependency',
|
||||
targetId: rel.targetAppId,
|
||||
dependencyType: rel.mode === 'autoInstall' ? 'autoInstall' : 'detect'
|
||||
};
|
||||
}
|
||||
return {
|
||||
'@odata.type': '#microsoft.graph.mobileAppSupersedence',
|
||||
targetId: rel.targetAppId,
|
||||
supersedenceType: rel.mode === 'replace' ? 'replace' : 'update'
|
||||
};
|
||||
});
|
||||
return { relationships: mapped };
|
||||
}
|
||||
Reference in New Issue
Block a user