This commit is contained in:
2026-06-25 12:05:53 -05:00
commit b58e50e423
141 changed files with 28190 additions and 0 deletions

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