551 lines
27 KiB
JavaScript
551 lines
27 KiB
JavaScript
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 { 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';
|
|
import { basenameAny, path } from '../utils/pathUtils.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 outputName = basenameAny(outputFile, 'package.intunewin');
|
|
const asset = createAssetFromUpload(
|
|
{ path: outputFile, originalname: outputName, mimetype: 'application/octet-stream', size: stats.size },
|
|
{ name: `${deployment.name} package`, visibility: deployment.visibility, groupId: deployment.groupId, packagePath: outputName },
|
|
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;
|
|
}
|