Initial
This commit is contained in:
94
server/controllers/applicationController.js
Normal file
94
server/controllers/applicationController.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { applicationImportSchema, applicationSchema } from '../forms/schemas.js';
|
||||
import {
|
||||
createApplication,
|
||||
deleteApplication,
|
||||
listApplications,
|
||||
loadApplication,
|
||||
recordVersionCheck,
|
||||
updateApplication
|
||||
} from '../models/applicationModel.js';
|
||||
import { getGraphConnection } from '../models/graphModel.js';
|
||||
import { getGraphMobileApp } from '../services/graphService.js';
|
||||
import { fetchLatestVersion, mapGraphAppToCatalog } from '../services/appVersionService.js';
|
||||
import { runCatalogChecks } from '../services/catalogWorker.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listApplications(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = applicationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid application payload' });
|
||||
res.status(201).json(createApplication(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const application = loadApplication(req.params.id, req.user.id);
|
||||
if (!application) return res.status(404).json({ error: 'Application not found' });
|
||||
res.json(application);
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const parsed = applicationSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid application payload' });
|
||||
const application = updateApplication(req.params.id, parsed.data, req.user.id);
|
||||
if (!application) return res.status(404).json({ error: 'Application not found' });
|
||||
res.json(application);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteApplication(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
// Look up the upstream version from the configured source and store it. The
|
||||
// update state (whether a newer version exists) is recomputed on read.
|
||||
export async function checkUpdate(req, res) {
|
||||
const application = loadApplication(req.params.id, req.user.id);
|
||||
if (!application) return res.status(404).json({ error: 'Application not found' });
|
||||
try {
|
||||
const result = await fetchLatestVersion(application);
|
||||
const updated = recordVersionCheck(req.params.id, req.user.id, {
|
||||
latestKnownVersion: result.version || application.latestKnownVersion,
|
||||
message: result.message || ''
|
||||
});
|
||||
res.json({ application: updated, check: result });
|
||||
} catch (error) {
|
||||
recordVersionCheck(req.params.id, req.user.id, { latestKnownVersion: application.latestKnownVersion, message: error.message });
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Run an upstream check for every auto-check application now (admin trigger).
|
||||
export async function checkAll(req, res) {
|
||||
try {
|
||||
const summary = await runCatalogChecks();
|
||||
res.json({ summary, applications: listApplications(req.user.id) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Adopt an existing tenant Win32 app into the catalog (migration aid). Read-only
|
||||
// Graph access; no write-back gate required.
|
||||
export async function importFromTenant(req, res) {
|
||||
const parsed = applicationImportSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'connectionId and graphAppId are required' });
|
||||
const connection = getGraphConnection(parsed.data.connectionId, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
try {
|
||||
const graphApp = await getGraphMobileApp(connection, parsed.data.graphAppId);
|
||||
const mapped = mapGraphAppToCatalog(graphApp);
|
||||
const application = createApplication({
|
||||
...mapped,
|
||||
graphConnectionId: parsed.data.connectionId,
|
||||
versionSource: 'manual',
|
||||
visibility: parsed.data.visibility,
|
||||
groupId: parsed.data.groupId,
|
||||
notes: `Adopted from Intune mobileApp ${parsed.data.graphAppId}.`
|
||||
}, req.user.id);
|
||||
res.status(201).json(application);
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
115
server/controllers/assetController.js
Normal file
115
server/controllers/assetController.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
assetFolderSchema,
|
||||
assetLinkSchema,
|
||||
assetUpdateSchema,
|
||||
assetUploadSchema
|
||||
} from '../forms/schemas.js';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
createAssetFolder,
|
||||
createAssetFromUpload,
|
||||
createAssetLink,
|
||||
deleteAsset,
|
||||
deleteAssetFolder,
|
||||
deleteAssetLink,
|
||||
getAsset,
|
||||
getAssetFile,
|
||||
listAssetFolders,
|
||||
listAssetLinks,
|
||||
listAssets,
|
||||
updateAsset,
|
||||
updateAssetFolder
|
||||
} from '../models/assetModel.js';
|
||||
|
||||
export function assetFolders(req, res) {
|
||||
res.json(listAssetFolders(req.user.id));
|
||||
}
|
||||
|
||||
export function createAssetFolderAction(req, res) {
|
||||
const parsed = assetFolderSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset folder payload' });
|
||||
res.status(201).json(createAssetFolder(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateAssetFolderAction(req, res) {
|
||||
const parsed = assetFolderSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset folder payload' });
|
||||
const folder = updateAssetFolder(req.params.id, parsed.data, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Asset folder not found' });
|
||||
res.json(folder);
|
||||
}
|
||||
|
||||
export function deleteAssetFolderAction(req, res) {
|
||||
deleteAssetFolder(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function assets(req, res) {
|
||||
res.json(listAssets(req.user.id));
|
||||
}
|
||||
|
||||
export function createAssetAction(req, res) {
|
||||
const parsed = assetUploadSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
|
||||
return res.status(400).json({ error: 'Invalid asset upload payload' });
|
||||
}
|
||||
try {
|
||||
res.status(201).json(createAssetFromUpload(req.file, parsed.data, req.user.id));
|
||||
} catch (error) {
|
||||
if (req.file?.path) fs.rmSync(req.file.path, { force: true });
|
||||
res.status(400).json({ error: error.message || 'Asset upload failed' });
|
||||
}
|
||||
}
|
||||
|
||||
export function getAssetAction(req, res) {
|
||||
const asset = getAsset(req.params.id, req.user.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(asset);
|
||||
}
|
||||
|
||||
export function updateAssetAction(req, res) {
|
||||
const parsed = assetUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset payload' });
|
||||
const asset = updateAsset(req.params.id, parsed.data, req.user.id);
|
||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(asset);
|
||||
}
|
||||
|
||||
export function deleteAssetAction(req, res) {
|
||||
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function viewAssetAction(req, res) {
|
||||
const result = getAssetFile(req.params.id, req.user.id);
|
||||
if (!result) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.type(result.asset.mimeType || 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(result.asset.originalName)}"`);
|
||||
res.sendFile(result.filePath);
|
||||
}
|
||||
|
||||
export function downloadAssetAction(req, res) {
|
||||
const result = getAssetFile(req.params.id, req.user.id);
|
||||
if (!result) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.download(result.filePath, result.asset.originalName);
|
||||
}
|
||||
|
||||
export function assetLinks(req, res) {
|
||||
const links = listAssetLinks(req.params.id, req.user.id);
|
||||
if (!links) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(links);
|
||||
}
|
||||
|
||||
export function createAssetLinkAction(req, res) {
|
||||
const parsed = assetLinkSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid asset link payload' });
|
||||
const link = createAssetLink(req.params.id, parsed.data, req.user.id);
|
||||
if (!link) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.status(201).json(link);
|
||||
}
|
||||
|
||||
export function deleteAssetLinkAction(req, res) {
|
||||
if (!deleteAssetLink(req.params.linkId, req.user.id)) return res.status(404).json({ error: 'Asset link not found' });
|
||||
res.status(204).end();
|
||||
}
|
||||
81
server/controllers/authController.js
Normal file
81
server/controllers/authController.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { authenticateLocal, publicUser, signUser } from '../auth.js';
|
||||
import { loginSchema, passwordChangeSchema, profileSchema, themeSchema } from '../forms/schemas.js';
|
||||
import { changePassword, updateProfile, updateTheme } from '../models/profileModel.js';
|
||||
import { findOrProvisionEntraUser } from '../models/userModel.js';
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForToken,
|
||||
fetchEntraProfile,
|
||||
isEntraLoginConfigured,
|
||||
signState,
|
||||
verifyState
|
||||
} from '../services/entraService.js';
|
||||
import { getSetting } from '../models/settingsModel.js';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
export function login(req, res) {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid email and password are required' });
|
||||
const user = authenticateLocal(parsed.data.email, parsed.data.password);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
res.json({ token: signUser(user), user: publicUser(user) });
|
||||
}
|
||||
|
||||
export function me(req, res) {
|
||||
res.json({ user: req.user });
|
||||
}
|
||||
|
||||
// Step 1: redirect the browser to the Microsoft sign-in page. 404 when Entra
|
||||
// login is not fully configured so the client cleanly falls back to local auth.
|
||||
export function entraLogin(req, res) {
|
||||
if (!isEntraLoginConfigured()) return res.status(404).json({ error: 'Entra login is not enabled' });
|
||||
const state = signState({ nonce: Date.now() });
|
||||
return res.redirect(buildAuthorizeUrl(state));
|
||||
}
|
||||
|
||||
// Step 2: Microsoft redirects back here with an authorization code. We validate
|
||||
// the signed state, exchange the code, provision the user, and hand the SPA a
|
||||
// POSHManager JWT via the app URL.
|
||||
export async function entraCallback(req, res) {
|
||||
const appBase = (getSetting('server_fqdn') || config.serverFqdn || '').replace(/\/+$/, '');
|
||||
if (!isEntraLoginConfigured()) return res.status(404).json({ error: 'Entra login is not enabled' });
|
||||
try {
|
||||
if (req.query.error) throw new Error(String(req.query.error_description || req.query.error));
|
||||
verifyState(String(req.query.state || ''));
|
||||
const tokens = await exchangeCodeForToken(String(req.query.code || ''));
|
||||
const profile = await fetchEntraProfile(tokens.access_token);
|
||||
const user = findOrProvisionEntraUser(profile);
|
||||
const token = signUser(user);
|
||||
return res.redirect(`${appBase}/?entraToken=${encodeURIComponent(token)}`);
|
||||
} catch (error) {
|
||||
logger.error('entra callback failed', { error: error.message });
|
||||
return res.redirect(`${appBase}/?entraError=${encodeURIComponent(error.message)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function profile(req, res) {
|
||||
const parsed = profileSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid profile information is required' });
|
||||
try {
|
||||
res.json({ user: updateProfile(req.user.id, parsed.data) });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function theme(req, res) {
|
||||
const parsed = themeSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Valid theme id is required' });
|
||||
res.json({ user: updateTheme(req.user.id, parsed.data.theme) });
|
||||
}
|
||||
|
||||
export function password(req, res) {
|
||||
const parsed = passwordChangeSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
||||
try {
|
||||
res.json(changePassword(req.user.id, parsed.data));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
17
server/controllers/bootstrapController.js
vendored
Normal file
17
server/controllers/bootstrapController.js
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
import { config } from '../config.js';
|
||||
import { getBootstrap } from '../models/bootstrapModel.js';
|
||||
import { getSetting } from '../models/settingsModel.js';
|
||||
import { isEntraLoginConfigured } from '../services/entraService.js';
|
||||
|
||||
export function health(req, res) {
|
||||
res.json({
|
||||
ok: true,
|
||||
nodeEnv: config.nodeEnv,
|
||||
serverFqdn: getSetting('server_fqdn') || config.serverFqdn,
|
||||
entraLoginEnabled: isEntraLoginConfigured()
|
||||
});
|
||||
}
|
||||
|
||||
export function bootstrap(req, res) {
|
||||
res.json(getBootstrap(req.user.id));
|
||||
}
|
||||
23
server/controllers/credentialController.js
Normal file
23
server/controllers/credentialController.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { credentialSchema } from '../forms/schemas.js';
|
||||
import { createCredential, deleteCredential, listCredentials, updateCredential } from '../models/credentialModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listCredentials(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = credentialSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid credential payload' });
|
||||
res.status(201).json(createCredential(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const credential = updateCredential(req.params.id, req.body, req.user.id);
|
||||
if (!credential) return res.status(404).json({ error: 'Credential not found' });
|
||||
res.json(credential);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteCredential(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
60
server/controllers/governanceController.js
Normal file
60
server/controllers/governanceController.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import { decideChangeRequest, getChangeRequest, listChangeRequests } from '../models/changeRequestModel.js';
|
||||
import { getConnectionGovernance, intuneReportingOverview, listGraphAudit } from '../models/graphModel.js';
|
||||
import { loadIntuneDeployment } from '../models/psadtModel.js';
|
||||
import { canApprove } from '../services/graphRbac.js';
|
||||
|
||||
export function changeRequests(req, res) {
|
||||
res.json(listChangeRequests(req.user.id, { status: req.query.status, isAdmin: req.user.role === 'admin' }));
|
||||
}
|
||||
|
||||
// Admins can always decide; otherwise the user must be a named approver on the
|
||||
// request's Graph connection.
|
||||
function authorizedApprover(req, request) {
|
||||
if (req.user.role === 'admin') return true;
|
||||
const governance = request.connectionId ? getConnectionGovernance(request.connectionId) : null;
|
||||
return canApprove({ approverIds: governance?.approverIds || [] }, req.user);
|
||||
}
|
||||
|
||||
function decide(req, res, status) {
|
||||
const request = getChangeRequest(req.params.id);
|
||||
if (!request || request.status !== 'pending') return res.status(404).json({ error: 'Pending change request not found' });
|
||||
if (!authorizedApprover(req, request)) return res.status(403).json({ error: 'You are not an authorized approver for this connection.' });
|
||||
const decided = decideChangeRequest(req.params.id, {
|
||||
status,
|
||||
approverUserId: req.user.id,
|
||||
approverEmail: req.user.email,
|
||||
reason: req.body?.reason || ''
|
||||
});
|
||||
if (!decided) return res.status(404).json({ error: 'Pending change request not found' });
|
||||
res.json(decided);
|
||||
}
|
||||
|
||||
export function approveChangeRequest(req, res) {
|
||||
decide(req, res, 'approved');
|
||||
}
|
||||
|
||||
export function rejectChangeRequest(req, res) {
|
||||
decide(req, res, 'rejected');
|
||||
}
|
||||
|
||||
export function reportingOverview(req, res) {
|
||||
res.json(intuneReportingOverview(req.user.id, req.user.role === 'admin'));
|
||||
}
|
||||
|
||||
function toCsv(rows) {
|
||||
const headers = ['createdAt', 'action', 'status', 'actorEmail', 'targetGraphId', 'message'];
|
||||
const escape = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||
const lines = [headers.join(',')];
|
||||
for (const row of rows) lines.push(headers.map((h) => escape(row[h])).join(','));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// CSV export of the tenant write-back audit trail for one deployment.
|
||||
export function auditExport(req, res) {
|
||||
const deployment = loadIntuneDeployment(req.params.id, req.user.id);
|
||||
if (!deployment) return res.status(404).json({ error: 'Intune deployment not found' });
|
||||
const rows = listGraphAudit({ deploymentId: deployment.id, limit: 1000 });
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="audit-${deployment.id}.csv"`);
|
||||
res.send(toCsv(rows));
|
||||
}
|
||||
65
server/controllers/graphController.js
Normal file
65
server/controllers/graphController.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { graphConnectionSchema } from '../forms/schemas.js';
|
||||
import { createGraphConnection, deleteGraphConnection, getGraphConnection, listGraphConnections, recordGraphConnectionTest, updateGraphConnection } from '../models/graphModel.js';
|
||||
import { listGraphMobileApps, testGraphConnection } from '../services/graphService.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listGraphConnections(req.user.id));
|
||||
}
|
||||
|
||||
// Governance flags (tenant write-back, approval requirement) are administrative.
|
||||
// On create, non-admins always get read-only. On update, non-admins keep the
|
||||
// existing flag values so they can never grant or silently downgrade them.
|
||||
export function create(req, res) {
|
||||
const parsed = graphConnectionSchema.safeParse(req.body);
|
||||
if (!parsed.success || !parsed.data.clientSecret) return res.status(400).json({ error: 'Valid Graph connection payload with client secret is required' });
|
||||
if (req.user.role !== 'admin') {
|
||||
parsed.data.allowWrite = false;
|
||||
parsed.data.requireApproval = false;
|
||||
parsed.data.publisherIds = [];
|
||||
parsed.data.approverIds = [];
|
||||
}
|
||||
res.status(201).json(createGraphConnection(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const parsed = graphConnectionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid Graph connection payload' });
|
||||
if (req.user.role !== 'admin') {
|
||||
const existing = getGraphConnection(req.params.id, req.user.id);
|
||||
parsed.data.allowWrite = Boolean(existing?.allowWrite);
|
||||
parsed.data.requireApproval = Boolean(existing?.requireApproval);
|
||||
parsed.data.publisherIds = existing?.publisherIds || [];
|
||||
parsed.data.approverIds = existing?.approverIds || [];
|
||||
}
|
||||
const connection = updateGraphConnection(req.params.id, parsed.data, req.user.id);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
res.json(connection);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteGraphConnection(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export async function test(req, res) {
|
||||
const connection = getGraphConnection(req.params.id, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
try {
|
||||
const result = await testGraphConnection(connection);
|
||||
recordGraphConnectionTest(req.params.id, 'success', result.message);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
recordGraphConnectionTest(req.params.id, 'failed', error.message);
|
||||
res.status(400).json({ ok: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function mobileApps(req, res) {
|
||||
const connection = getGraphConnection(req.params.id, req.user.id, true);
|
||||
if (!connection) return res.status(404).json({ error: 'Graph connection not found' });
|
||||
try {
|
||||
res.json(await listGraphMobileApps(connection, String(req.query.q || '')));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
12
server/controllers/groupController.js
Normal file
12
server/controllers/groupController.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { groupSchema } from '../forms/schemas.js';
|
||||
import { createGroup, listGroups } from '../models/groupModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listGroups());
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = groupSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid group payload' });
|
||||
res.status(201).json(createGroup(parsed.data));
|
||||
}
|
||||
14
server/controllers/helpController.js
Normal file
14
server/controllers/helpController.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getHelpArticle, listHelp } from '../models/helpModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listHelp({
|
||||
query: String(req.query.q || ''),
|
||||
category: String(req.query.category || '')
|
||||
}));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const article = getHelpArticle(req.params.id);
|
||||
if (!article) return res.status(404).json({ error: 'Help article not found' });
|
||||
res.json(article);
|
||||
}
|
||||
23
server/controllers/hostController.js
Normal file
23
server/controllers/hostController.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { hostSchema } from '../forms/schemas.js';
|
||||
import { createHost, deleteHost, listHosts, updateHost } from '../models/hostModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listHosts(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = hostSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid host payload' });
|
||||
res.status(201).json(createHost(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const host = updateHost(req.params.id, req.body, req.user.id);
|
||||
if (!host) return res.status(404).json({ error: 'Host not found' });
|
||||
res.json(host);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteHost(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
11
server/controllers/jobController.js
Normal file
11
server/controllers/jobController.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getJob, listJobs } from '../models/jobModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listJobs(req.user.id, req.user.role === 'admin'));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const job = getJob(req.params.id, req.user.id, req.user.role === 'admin');
|
||||
if (!job) return res.status(404).json({ error: 'Job not found' });
|
||||
res.json(job);
|
||||
}
|
||||
101
server/controllers/libraryController.js
Normal file
101
server/controllers/libraryController.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { folderSchema, scriptTestRunSchema } from '../forms/schemas.js';
|
||||
import {
|
||||
cloneScript,
|
||||
createFolder,
|
||||
createScript,
|
||||
deleteFolder,
|
||||
deleteScript,
|
||||
getScript,
|
||||
listFolders,
|
||||
listScripts,
|
||||
listScriptVersions,
|
||||
scriptUsage,
|
||||
updateFolder,
|
||||
updateScript
|
||||
} from '../models/libraryModel.js';
|
||||
import { camelJob } from '../models/mappers.js';
|
||||
import { executeScriptTest } from '../services/powershellRunner.js';
|
||||
|
||||
export function folders(req, res) {
|
||||
res.json(listFolders(req.user.id));
|
||||
}
|
||||
|
||||
export function createFolderAction(req, res) {
|
||||
const parsed = folderSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid folder payload' });
|
||||
res.status(201).json(createFolder(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateFolderAction(req, res) {
|
||||
const folder = updateFolder(req.params.id, req.body, req.user.id);
|
||||
if (!folder) return res.status(404).json({ error: 'Folder not found' });
|
||||
res.json(folder);
|
||||
}
|
||||
|
||||
export function deleteFolderAction(req, res) {
|
||||
deleteFolder(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function scripts(req, res) {
|
||||
res.json(listScripts(req.user.id));
|
||||
}
|
||||
|
||||
export function createScriptAction(req, res) {
|
||||
// Model normalization creates the script row and initial version in one backend transaction path.
|
||||
res.status(201).json(createScript(req.body, req.user.id));
|
||||
}
|
||||
|
||||
export function cloneScriptAction(req, res) {
|
||||
const script = cloneScript(req.params.id, req.body || {}, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.status(201).json(script);
|
||||
}
|
||||
|
||||
export function getScriptAction(req, res) {
|
||||
const script = getScript(req.params.id, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(script);
|
||||
}
|
||||
|
||||
export function updateScriptAction(req, res) {
|
||||
// Updating script content also appends a version record for audit and recovery.
|
||||
const script = updateScript(req.params.id, req.body, req.user.id);
|
||||
if (!script) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(script);
|
||||
}
|
||||
|
||||
export function deleteScriptAction(req, res) {
|
||||
const usage = scriptUsage(req.params.id, req.user.id);
|
||||
if (!usage) return res.status(404).json({ error: 'Script not found' });
|
||||
if (usage.total && req.query.force !== 'true') {
|
||||
return res.status(409).json({
|
||||
error: 'Script is assigned to RunPlans or Intune deployments. Confirm deletion with force=true.',
|
||||
usage
|
||||
});
|
||||
}
|
||||
deleteScript(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function scriptUsageAction(req, res) {
|
||||
const usage = scriptUsage(req.params.id, req.user.id);
|
||||
if (!usage) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(usage);
|
||||
}
|
||||
|
||||
export function testRunScriptAction(req, res) {
|
||||
const parsed = scriptTestRunSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid test run payload' });
|
||||
try {
|
||||
res.status(202).json(camelJob(executeScriptTest(req.params.id, parsed.data, req.user.id)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export function scriptVersions(req, res) {
|
||||
const versions = listScriptVersions(req.params.id, req.user.id);
|
||||
if (!versions) return res.status(404).json({ error: 'Script not found' });
|
||||
res.json(versions);
|
||||
}
|
||||
5
server/controllers/logController.js
Normal file
5
server/controllers/logController.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { readSystemLog } from '../services/logger.js';
|
||||
|
||||
export function systemLogs(req, res) {
|
||||
res.json(readSystemLog(Number(req.query.limit || 300)));
|
||||
}
|
||||
20
server/controllers/packagingController.js
Normal file
20
server/controllers/packagingController.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { analyzeInstaller, listInstallerTechnologies } from '../services/installerIntelService.js';
|
||||
import { buildDetectionRule } from '../services/detectionRuleService.js';
|
||||
|
||||
export function installerTypes(req, res) {
|
||||
res.json(listInstallerTechnologies());
|
||||
}
|
||||
|
||||
export function analyze(req, res) {
|
||||
const fileName = String(req.body?.fileName || '').trim();
|
||||
if (!fileName) return res.status(400).json({ error: 'fileName is required' });
|
||||
res.json(analyzeInstaller({ fileName, productCode: req.body?.productCode || '' }));
|
||||
}
|
||||
|
||||
export function detection(req, res) {
|
||||
try {
|
||||
res.json(buildDetectionRule(req.body || {}));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
39
server/controllers/runPlanController.js
Normal file
39
server/controllers/runPlanController.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { camelJob } from '../models/mappers.js';
|
||||
import { createRunPlan, deleteRunPlan, listRunPlans, loadVisibleRunPlan, updateRunPlan } from '../models/runPlanModel.js';
|
||||
import { executeRunPlan } from '../services/powershellRunner.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listRunPlans(req.user.id));
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
res.status(201).json(createRunPlan(req.body, req.user.id));
|
||||
}
|
||||
|
||||
export function show(req, res) {
|
||||
const runplan = loadVisibleRunPlan(req.params.id, req.user.id);
|
||||
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
|
||||
res.json(runplan);
|
||||
}
|
||||
|
||||
export function update(req, res) {
|
||||
const runplan = updateRunPlan(req.params.id, req.body, req.user.id);
|
||||
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
|
||||
res.json(runplan);
|
||||
}
|
||||
|
||||
export function destroy(req, res) {
|
||||
deleteRunPlan(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
|
||||
export function execute(req, res) {
|
||||
// Execution remains an API concern; Vue only requests a RunPlan launch.
|
||||
const runplan = loadVisibleRunPlan(req.params.id, req.user.id);
|
||||
if (!runplan) return res.status(404).json({ error: 'RunPlan not found' });
|
||||
try {
|
||||
res.status(202).json(camelJob(executeRunPlan(req.params.id, req.user.id)));
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
9
server/controllers/settingsController.js
Normal file
9
server/controllers/settingsController.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { settingsPayload, updateSettings } from '../models/settingsModel.js';
|
||||
|
||||
export function listSettings(req, res) {
|
||||
res.json(settingsPayload());
|
||||
}
|
||||
|
||||
export function saveSettings(req, res) {
|
||||
res.json(updateSettings(req.body));
|
||||
}
|
||||
12
server/controllers/userController.js
Normal file
12
server/controllers/userController.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { userSchema } from '../forms/schemas.js';
|
||||
import { createUser, listUsers } from '../models/userModel.js';
|
||||
|
||||
export function index(req, res) {
|
||||
res.json(listUsers());
|
||||
}
|
||||
|
||||
export function create(req, res) {
|
||||
const parsed = userSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid user payload' });
|
||||
res.status(201).json(createUser(parsed.data));
|
||||
}
|
||||
33
server/controllers/variableController.js
Normal file
33
server/controllers/variableController.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { customVariableSchema } from '../forms/schemas.js';
|
||||
import {
|
||||
createCustomVariable,
|
||||
deleteCustomVariable,
|
||||
listCustomVariables,
|
||||
listVariableCatalog,
|
||||
updateCustomVariable
|
||||
} from '../models/variableModel.js';
|
||||
|
||||
export function catalog(req, res) {
|
||||
res.json(listVariableCatalog(req.user.id));
|
||||
}
|
||||
|
||||
export function customIndex(req, res) {
|
||||
res.json(listCustomVariables(req.user.id));
|
||||
}
|
||||
|
||||
export function createCustom(req, res) {
|
||||
const parsed = customVariableSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: 'Invalid variable payload' });
|
||||
res.status(201).json(createCustomVariable(parsed.data, req.user.id));
|
||||
}
|
||||
|
||||
export function updateCustom(req, res) {
|
||||
const variable = updateCustomVariable(req.params.id, req.body, req.user.id);
|
||||
if (!variable) return res.status(404).json({ error: 'Variable not found' });
|
||||
res.json(variable);
|
||||
}
|
||||
|
||||
export function destroyCustom(req, res) {
|
||||
deleteCustomVariable(req.params.id, req.user.id);
|
||||
res.status(204).end();
|
||||
}
|
||||
Reference in New Issue
Block a user