import { graphDeploymentLinkSchema, graphDeploymentSyncSchema, intuneDeploymentSchema, psadtMigrationSchema, psadtProfileSchema, psadtValidationSchema } from '../forms/schemas.js'; import { createSyncRun, finishSyncRun, getGraphConnection, listDeploymentStatuses, listDeploymentSyncRuns, listGraphAudit, recordGraphAudit, upsertDeploymentStatuses } from '../models/graphModel.js'; import { createScript, getScript } from '../models/libraryModel.js'; import { createIntuneDeployment, createPsadtProfile, deleteIntuneDeployment, deletePsadtProfile, getPsadtCatalog, listIntuneDeployments, listPsadtProfiles, loadIntuneDeployment, loadPsadtProfile, markDeploymentPublished, renderPsadtProfile, updateIntuneDeployment, updatePsadtProfile } from '../models/psadtModel.js'; import fs from 'node:fs'; import path from 'node:path'; import { config } from '../config.js'; import { createChangeRequest, findApprovedRequest, findPendingRequest, markRequestExecuted } from '../models/changeRequestModel.js'; import { buildIntuneWin, builderAvailability } from '../services/intuneWinBuilder.js'; import { assignWin32App, getWin32AppState, setWin32AppRelationships, syncIntuneDeploymentStatus, uploadWin32Content, upsertWin32App } from '../services/graphService.js'; import { buildAssignmentsPayload, buildRelationshipsPayload, buildWin32LobAppPayload } from '../services/intunePublishService.js'; import { computeDrift } from '../services/intuneDriftService.js'; import { canPublish } from '../services/graphRbac.js'; import { cloneForNextVersion, setRingIntent } from '../services/intunePromotionService.js'; import { parseIntunewin } from '../services/intunewinParser.js'; import { createAssetFromUpload, getAssetFile } from '../models/assetModel.js'; import { validatePsadtScript, validationRuleCatalog } from '../services/psadtValidator.js'; import { planPsadtMigration } from '../services/psadtMigrationService.js'; export function catalog(req, res) { res.json(getPsadtCatalog()); } export function intuneIndex(req, res) { res.json(listIntuneDeployments(req.user.id)); } export function intuneCreate(req, res) { const parsed = intuneDeploymentSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' }); res.status(201).json(createIntuneDeployment(parsed.data, req.user.id)); } export function intuneShow(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); res.json(deployment); } export function intuneUpdate(req, res) { const parsed = intuneDeploymentSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid Intune deployment payload' }); const deployment = updateIntuneDeployment(req.params.id, parsed.data, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); res.json(deployment); } export function intuneDestroy(req, res) { deleteIntuneDeployment(req.params.id, req.user.id); res.status(204).end(); } export function intuneGraphStatus(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); const statuses = listDeploymentStatuses(deployment.id); const syncRuns = listDeploymentSyncRuns(deployment.id); res.json({ deployment, summary: summarizeInstallStatuses(statuses), failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode), statuses, syncRuns }); } export function intuneGraphLink(req, res) { const parsed = graphDeploymentLinkSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Valid connectionId and graphAppId are required' }); const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); const updated = updateIntuneDeployment(req.params.id, { ...deployment, graphConnectionId: parsed.data.connectionId || deployment.graphConnectionId || null, graphAppId: parsed.data.graphAppId }, req.user.id); res.json(updated); } export async function intuneGraphSync(req, res) { const parsed = graphDeploymentSyncSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph sync payload' }); const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); const connectionId = parsed.data.connectionId || deployment.graphConnectionId; const graphAppId = parsed.data.graphAppId || deployment.graphAppId; if (!connectionId || !graphAppId) return res.status(400).json({ error: 'Graph connection and Intune mobile app id are required' }); const connection = getGraphConnection(connectionId, req.user.id, true); if (!connection) return res.status(404).json({ error: 'Graph connection not found' }); const runId = createSyncRun(deployment.id, connectionId, graphAppId); try { const result = await syncIntuneDeploymentStatus(connection, graphAppId, parsed.data.mode); upsertDeploymentStatuses(deployment.id, connectionId, graphAppId, result.rows); finishSyncRun(runId, 'success', `Synced ${result.rows.length} status rows from ${result.sources.join(', ') || 'Graph'}`, result.summary); const updated = updateIntuneDeployment(req.params.id, { ...deployment, graphConnectionId: connectionId, graphAppId }, req.user.id); const statuses = listDeploymentStatuses(deployment.id); res.json({ deployment: updated, summary: summarizeInstallStatuses(statuses), failures: statuses.filter((row) => row.installState === 'failed' || row.errorCode), statuses, syncRuns: listDeploymentSyncRuns(deployment.id) }); } catch (error) { finishSyncRun(runId, 'failed', error.message, {}); res.status(400).json({ error: error.message, syncRuns: listDeploymentSyncRuns(deployment.id) }); } } // Shared resolver for tenant-changing Graph actions: loads the visible // deployment and a write-enabled Graph connection (with secret), or sends the // appropriate error and returns null. function resolveWriteContext(req, res) { const parsed = graphDeploymentSyncSchema.safeParse(req.body || {}); const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) { res.status(404).json({ error: 'Intune deployment not found' }); return null; } const connectionId = (parsed.success && parsed.data.connectionId) || deployment.graphConnectionId; if (!connectionId) { res.status(400).json({ error: 'A Graph connection is required for tenant write-back' }); return null; } const connection = getGraphConnection(connectionId, req.user.id, true); if (!connection) { res.status(404).json({ error: 'Graph connection not found' }); return null; } if (!connection.allow_write) { res.status(403).json({ error: 'This Graph connection is read-only. An admin must enable write-back first.' }); return null; } if (!canPublish(connection, req.user)) { res.status(403).json({ error: 'You are not an authorized publisher for this Graph connection.' }); return null; } return { deployment, connection, connectionId }; } // Governance gate: when a connection requires approval, a tenant-changing action // only proceeds if an approved (not-yet-executed) change request exists for it. // Otherwise a pending request is created and the caller is told to wait. Returns // { proceed, requestId } — when proceed is false a 202 response has been sent. function ensureApproved(ctx, action, req, res) { if (!ctx.connection.require_approval) return { proceed: true, requestId: null }; const approved = findApprovedRequest(ctx.deployment.id, action); if (approved) return { proceed: true, requestId: approved.id }; const request = findPendingRequest(ctx.deployment.id, action) || createChangeRequest({ deploymentId: ctx.deployment.id, connectionId: ctx.connectionId, action, requestedBy: req.user.id, requesterEmail: req.user.email }); res.status(202).json({ status: 'approval-required', message: 'This action requires approval before it runs.', request }); return { proceed: false }; } // Phase 1: publish (create/update) the win32LobApp metadata in the tenant from // a deployment plan. Every attempt is audit-logged with actor, request, response. export async function intuneGraphPublish(req, res) { const ctx = resolveWriteContext(req, res); if (!ctx) return; const approval = ensureApproved(ctx, 'publish', req, res); if (!approval.proceed) return; const { deployment, connection, connectionId } = ctx; const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null; const { payload, warnings } = buildWin32LobAppPayload(deployment, profile); const auditBase = { connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email, action: deployment.graphAppId ? 'win32app.update' : 'win32app.create', request: payload }; try { const result = await upsertWin32App(connection, payload, deployment.graphAppId); const updated = updateIntuneDeployment(req.params.id, { ...deployment, graphConnectionId: connectionId, graphAppId: result.id, status: 'published' }, req.user.id); if (approval.requestId) markRequestExecuted(approval.requestId); markDeploymentPublished(deployment.id); recordGraphAudit({ ...auditBase, targetGraphId: result.id, status: 'success', message: result.updated ? 'Updated win32LobApp' : 'Created win32LobApp', response: result.raw || { id: result.id } }); res.json({ deployment: updated, graphAppId: result.id, warnings }); } catch (error) { recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} }); res.status(400).json({ error: error.message, warnings }); } } // Phase 3: replace the app's assignments (rings, filters, exclusions) in tenant. export async function intuneGraphAssign(req, res) { const ctx = resolveWriteContext(req, res); if (!ctx) return; const { deployment, connection, connectionId } = ctx; if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before assigning it.' }); const approval = ensureApproved(ctx, 'assign', req, res); if (!approval.proceed) return; const { assignments, warnings } = buildAssignmentsPayload(deployment); const auditBase = { connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email, action: 'win32app.assign', targetGraphId: deployment.graphAppId, request: { mobileAppAssignments: assignments } }; try { const result = await assignWin32App(connection, deployment.graphAppId, assignments); if (approval.requestId) markRequestExecuted(approval.requestId); recordGraphAudit({ ...auditBase, status: 'success', message: `Assigned ${result.count} target(s)`, response: result }); res.json({ deployment, assigned: result.count, warnings }); } catch (error) { recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} }); res.status(400).json({ error: error.message, warnings }); } } // Phase 4: replace the app's supersedence/dependency relationships in tenant. export async function intuneGraphRelationships(req, res) { const ctx = resolveWriteContext(req, res); if (!ctx) return; const { deployment, connection, connectionId } = ctx; if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before setting relationships.' }); const approval = ensureApproved(ctx, 'relationships', req, res); if (!approval.proceed) return; let body; try { body = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId); } catch (error) { return res.status(400).json({ error: error.message, validation: error.validation || [] }); } const auditBase = { connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email, action: 'win32app.relationships', targetGraphId: deployment.graphAppId, request: body }; try { const result = await setWin32AppRelationships(connection, deployment.graphAppId, body.relationships); if (approval.requestId) markRequestExecuted(approval.requestId); recordGraphAudit({ ...auditBase, status: 'success', message: `Set ${result.count} relationship(s)`, response: result }); res.json({ deployment, relationships: result.count }); } catch (error) { recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} }); res.status(400).json({ error: error.message }); } } // Phase 2: upload the .intunewin content for a published app. The package is an // Asset Library file (assetId); we parse it locally for the encrypted payload + // encryption info, then run the Graph content-version/Azure-block-upload/commit // flow. NOTE: validated end to end only against a live tenant. export async function intuneGraphUploadContent(req, res) { const ctx = resolveWriteContext(req, res); if (!ctx) return; const { deployment, connection, connectionId } = ctx; if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before uploading content.' }); const approval = ensureApproved(ctx, 'upload-content', req, res); if (!approval.proceed) return; const assetId = req.body?.assetId; if (!assetId) return res.status(400).json({ error: 'assetId of the .intunewin package is required' }); const fileRef = getAssetFile(assetId, req.user.id); if (!fileRef) return res.status(404).json({ error: 'Asset not found' }); let parsed; try { parsed = parseIntunewin(fs.readFileSync(fileRef.filePath)); } catch (error) { return res.status(400).json({ error: `Could not read .intunewin package: ${error.message}` }); } const auditBase = { connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email, action: 'win32app.content-upload', targetGraphId: deployment.graphAppId, request: { assetId, setupFile: parsed.setupFile, size: parsed.unencryptedContentSize, sizeEncrypted: parsed.encryptedContentSize } }; try { const result = await uploadWin32Content(connection, deployment.graphAppId, parsed); updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: fileRef.asset.originalName, status: 'published' }, req.user.id); if (approval.requestId) markRequestExecuted(approval.requestId); recordGraphAudit({ ...auditBase, status: 'success', message: `Committed content version ${result.contentVersionId}`, response: result }); res.json({ deployment: loadIntuneDeployment(req.params.id, req.user.id), content: result }); } catch (error) { recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} }); res.status(400).json({ error: error.message }); } } // Reconcile: re-apply the full plan (metadata, assignments, relationships) to the // tenant to clear drift. Gated + approval-aware + audited as one action. export async function intuneGraphReconcile(req, res) { const ctx = resolveWriteContext(req, res); if (!ctx) return; const { deployment, connection, connectionId } = ctx; if (!deployment.graphAppId) return res.status(400).json({ error: 'Publish the app before reconciling.' }); const approval = ensureApproved(ctx, 'reconcile', req, res); if (!approval.proceed) return; const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null; const { payload } = buildWin32LobAppPayload(deployment, profile); const { assignments } = buildAssignmentsPayload(deployment); let relationshipsBody = { relationships: [] }; try { relationshipsBody = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId); } catch { /* invalid relationships are skipped; drift will still flag them */ } const auditBase = { connectionId, deploymentId: deployment.id, actorUserId: req.user.id, actorEmail: req.user.email, action: 'win32app.reconcile', targetGraphId: deployment.graphAppId, request: { app: payload, assignments, relationships: relationshipsBody.relationships } }; try { await upsertWin32App(connection, payload, deployment.graphAppId); await assignWin32App(connection, deployment.graphAppId, assignments); await setWin32AppRelationships(connection, deployment.graphAppId, relationshipsBody.relationships); if (approval.requestId) markRequestExecuted(approval.requestId); recordGraphAudit({ ...auditBase, status: 'success', message: 'Re-applied plan to tenant' }); res.json({ deployment, reconciled: true }); } catch (error) { recordGraphAudit({ ...auditBase, status: 'failed', message: error.message, response: error.payload || {} }); res.status(400).json({ error: error.message }); } } // Report whether this host can build .intunewin packages (Windows + tool, or an // external packager hook), so the UI can enable/disable the Build action. export function intuneBuilderStatus(req, res) { res.json(builderAvailability()); } // Build a .intunewin from a source folder using IntuneWinAppUtil (or the // configured packager) and ingest the result into the Asset Library, linking it // to the deployment for content upload. export async function intuneBuild(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); const sourceFolder = req.body?.sourceFolder || deployment.sourceFolder; const setupFile = req.body?.setupFile; if (!sourceFolder) return res.status(400).json({ error: 'A source folder is required (set it on the deployment or pass sourceFolder).' }); if (!setupFile) return res.status(400).json({ error: 'A setup file (entry installer relative to the source folder) is required.' }); const outputDir = path.join(config.toolsDir, '_build', deployment.id); try { const { outputFile } = await buildIntuneWin({ sourceFolder, setupFile, outputDir }); const stats = fs.statSync(outputFile); const asset = createAssetFromUpload( { path: outputFile, originalname: path.basename(outputFile), mimetype: 'application/octet-stream', size: stats.size }, { name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: path.basename(outputFile) }, req.user.id ); const updated = updateIntuneDeployment(req.params.id, { ...deployment, intunewinFile: asset.originalName }, req.user.id); res.status(201).json({ asset, deployment: updated }); } catch (error) { res.status(400).json({ error: error.message }); } } // New version: clone a plan into the next version, linked to the same catalog // application, ready to build/publish. Does not touch the tenant. export function intuneNewVersion(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); const clone = cloneForNextVersion(deployment, { version: req.body?.version, applicationId: req.body?.applicationId }); res.status(201).json(createIntuneDeployment(clone, req.user.id)); } // Promote a rollout ring to a new intent (e.g. Broad available -> required). // Updates the plan; the operator then re-assigns to push it to the tenant. export function intunePromote(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); const ring = req.body?.ring; const intent = req.body?.intent; if (!ring || !['available', 'required', 'uninstall', 'exclude'].includes(intent)) { return res.status(400).json({ error: 'A ring and a valid intent (available/required/uninstall/exclude) are required' }); } const { assignments, changed } = setRingIntent(deployment.assignments, ring, intent); if (!changed) return res.status(400).json({ error: `No assignment ring named "${ring}" to promote.` }); const updated = updateIntuneDeployment(req.params.id, { ...deployment, assignments }, req.user.id); res.json(updated); } // Drift check: compare the stored plan against the live tenant app. Read-only, // so it needs a Graph connection (with secret) but not the write gate. export async function intuneGraphDrift(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); if (!deployment.graphAppId) return res.status(400).json({ error: 'This deployment has not been published to a tenant yet.' }); const connectionId = req.body?.connectionId || req.query?.connectionId || deployment.graphConnectionId; if (!connectionId) return res.status(400).json({ error: 'A Graph connection is required to check drift' }); const connection = getGraphConnection(connectionId, req.user.id, true); if (!connection) return res.status(404).json({ error: 'Graph connection not found' }); const profile = deployment.profileId ? loadPsadtProfile(deployment.profileId, req.user.id) : null; const { payload: expectedApp } = buildWin32LobAppPayload(deployment, profile); const { assignments: expectedAssignments } = buildAssignmentsPayload(deployment); let expectedRelationships = []; try { expectedRelationships = buildRelationshipsPayload(deployment.relationships, deployment.graphAppId).relationships; } catch { expectedRelationships = []; } try { const live = await getWin32AppState(connection, deployment.graphAppId); const drift = computeDrift({ expectedApp, liveApp: live.app, expectedAssignments, liveAssignments: live.assignments, expectedRelationships, liveRelationships: live.relationships }); res.json({ deployment: { id: deployment.id, name: deployment.name, graphAppId: deployment.graphAppId }, ...drift }); } catch (error) { res.status(400).json({ error: error.message }); } } export function intuneGraphAudit(req, res) { const deployment = loadIntuneDeployment(req.params.id, req.user.id); if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' }); res.json(listGraphAudit({ deploymentId: deployment.id })); } export function index(req, res) { res.json(listPsadtProfiles(req.user.id)); } export function create(req, res) { const parsed = psadtProfileSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT profile payload' }); res.status(201).json(createPsadtProfile(parsed.data, req.user.id)); } export function show(req, res) { const profile = loadPsadtProfile(req.params.id, req.user.id); if (!profile) return res.status(404).json({ error: 'PSADT profile not found' }); res.json(profile); } export function update(req, res) { const profile = updatePsadtProfile(req.params.id, req.body, req.user.id); if (!profile) return res.status(404).json({ error: 'PSADT profile not found' }); res.json(profile); } export function destroy(req, res) { deletePsadtProfile(req.params.id, req.user.id); res.status(204).end(); } export function render(req, res) { const rendered = renderPsadtProfile(req.params.id, req.user.id); if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' }); res.json(rendered); } export function rules(req, res) { res.json(validationRuleCatalog()); } export function validate(req, res) { const parsed = psadtValidationSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT validation payload' }); let content = parsed.data.content || ''; const context = { platform: parsed.data.platform }; if (parsed.data.scriptId) { const script = getScript(parsed.data.scriptId, req.user.id); if (!script) return res.status(404).json({ error: 'Script not found' }); content = script.content || ''; context.scriptId = script.id; context.scriptName = script.name; } if (parsed.data.profileId) { const rendered = renderPsadtProfile(parsed.data.profileId, req.user.id); if (!rendered) return res.status(404).json({ error: 'PSADT profile not found' }); content = rendered.script; context.profileId = rendered.profile.id; context.profileName = rendered.profile.name; context.requireAdmin = rendered.profile.requireAdmin; } res.json(validatePsadtScript(content, context)); } export function migrationPlan(req, res) { const parsed = psadtMigrationSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' }); const { content, script } = resolveMigrationSource(parsed.data, req.user.id); if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' }); res.json({ sourceScript: script ? { id: script.id, name: script.name } : null, ...planPsadtMigration(content, { targetVersion: parsed.data.targetVersion }) }); } export function migrationApply(req, res) { const parsed = psadtMigrationSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Invalid PSADT migration payload' }); const { content, script } = resolveMigrationSource(parsed.data, req.user.id); if (parsed.data.scriptId && !script) return res.status(404).json({ error: 'Script not found' }); const plan = planPsadtMigration(content, { targetVersion: parsed.data.targetVersion }); let migratedScript = null; if (parsed.data.createScript) { migratedScript = createScript({ folderId: script?.folderId || null, name: `${script?.name || 'Migrated PSADT Script'} - ${parsed.data.nameSuffix}`, description: `Migrated to PSADT ${plan.targetVersion}${script ? ` from ${script.name}` : ''}. Review manual migration findings before production deployment.`, content: plan.migratedContent, visibility: script?.visibility || 'personal', groupId: script?.groupId || null }, req.user.id); } res.json({ ...plan, sourceScript: script ? { id: script.id, name: script.name } : null, migratedScript }); } function resolveMigrationSource(payload, userId) { if (payload.scriptId) { const script = getScript(payload.scriptId, userId); return { script, content: script?.content || '' }; } return { script: null, content: payload.content || '' }; } function summarizeInstallStatuses(statuses = []) { const summary = { total: statuses.length, installed: 0, failed: 0, pending: 0, notInstalled: 0, notApplicable: 0, unknown: 0, devices: 0, users: 0 }; for (const row of statuses) { if (row.scope === 'user') summary.users += 1; else summary.devices += 1; const key = row.installState || 'unknown'; if (summary[key] == null) summary.unknown += 1; else summary[key] += 1; } return summary; }