Initial
This commit is contained in:
549
server/controllers/psadtController.js
Normal file
549
server/controllers/psadtController.js
Normal file
@@ -0,0 +1,549 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user