Files
poshmanager/server/models/graphModel.js
2026-06-25 12:05:53 -05:00

358 lines
14 KiB
JavaScript

import { db, id, json, now, parseJson, visibleClause } from '../db.js';
import { graphConnectionSchema } from '../forms/schemas.js';
import { camelGraphConnection, camelIntuneDeploymentStatus, camelIntuneDeploymentSyncRun } from './mappers.js';
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
function normalizeConnection(body, existing = {}) {
const parsed = graphConnectionSchema.parse({ ...existing, ...body });
const cloudDefaults = cloudEndpointDefaults(parsed.cloud);
return {
...parsed,
graphBaseUrl: body.graphBaseUrl || existing.graphBaseUrl || cloudDefaults.graphBaseUrl,
authorityHost: body.authorityHost || existing.authorityHost || cloudDefaults.authorityHost,
enabled: Boolean(parsed.enabled),
allowWrite: Boolean(parsed.allowWrite),
requireApproval: Boolean(parsed.requireApproval),
publisherIds: Array.isArray(parsed.publisherIds) ? parsed.publisherIds : [],
approverIds: Array.isArray(parsed.approverIds) ? parsed.approverIds : [],
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
export function cloudEndpointDefaults(cloud = 'global') {
const endpoints = {
global: { graphBaseUrl: 'https://graph.microsoft.com', authorityHost: 'https://login.microsoftonline.com' },
gcc: { graphBaseUrl: 'https://graph.microsoft.com', authorityHost: 'https://login.microsoftonline.com' },
gcchigh: { graphBaseUrl: 'https://graph.microsoft.us', authorityHost: 'https://login.microsoftonline.us' },
dod: { graphBaseUrl: 'https://dod-graph.microsoft.us', authorityHost: 'https://login.microsoftonline.us' },
china: { graphBaseUrl: 'https://microsoftgraph.chinacloudapi.cn', authorityHost: 'https://login.chinacloudapi.cn' }
};
return endpoints[cloud] || endpoints.global;
}
export function listGraphConnections(userId) {
return db.prepare(`
SELECT c.*, g.name AS group_name
FROM graph_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE ${visibleClause('c')}
ORDER BY c.name
`).all(userId, userId).map(camelGraphConnection);
}
export function getGraphConnection(connectionId, userId, includeSecret = false) {
const row = db.prepare(`
SELECT c.*, g.name AS group_name
FROM graph_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE c.id = ? AND ${visibleClause('c')}
`).get(connectionId, userId, userId);
if (!row) return null;
return includeSecret ? { ...row, clientSecret: decryptSecret(row) } : camelGraphConnection(row);
}
export function createGraphConnection(body, userId) {
const payload = normalizeConnection(body);
const encrypted = encryptSecret(payload.clientSecret);
const connectionId = id('graph');
db.prepare(`
INSERT INTO graph_connections (
id, name, tenant_id, client_id, secret_cipher, secret_iv, secret_tag, cloud,
graph_base_url, authority_host, default_api_version, enabled, allow_write, require_approval,
publisher_ids, approver_ids, visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
connectionId,
payload.name,
payload.tenantId,
payload.clientId,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.cloud,
payload.graphBaseUrl,
payload.authorityHost,
payload.defaultApiVersion,
payload.enabled ? 1 : 0,
payload.allowWrite ? 1 : 0,
payload.requireApproval ? 1 : 0,
json(payload.publisherIds, []),
json(payload.approverIds, []),
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return getGraphConnection(connectionId, userId);
}
export function updateGraphConnection(connectionId, body, userId) {
const existing = getGraphConnection(connectionId, userId, true);
if (!existing) return null;
const payload = normalizeConnection(body, {
name: existing.name,
tenantId: existing.tenant_id,
clientId: existing.client_id,
cloud: existing.cloud,
graphBaseUrl: existing.graph_base_url,
authorityHost: existing.authority_host,
defaultApiVersion: existing.default_api_version,
enabled: Boolean(existing.enabled),
allowWrite: Boolean(existing.allow_write),
requireApproval: Boolean(existing.require_approval),
publisherIds: parseJson(existing.publisher_ids, []),
approverIds: parseJson(existing.approver_ids, []),
visibility: existing.visibility,
groupId: existing.group_id
});
const encrypted = payload.clientSecret ? encryptSecret(payload.clientSecret) : {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
};
db.prepare(`
UPDATE graph_connections
SET name = ?, tenant_id = ?, client_id = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
cloud = ?, graph_base_url = ?, authority_host = ?, default_api_version = ?, enabled = ?, allow_write = ?, require_approval = ?,
publisher_ids = ?, approver_ids = ?, visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.tenantId,
payload.clientId,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.cloud,
payload.graphBaseUrl,
payload.authorityHost,
payload.defaultApiVersion,
payload.enabled ? 1 : 0,
payload.allowWrite ? 1 : 0,
payload.requireApproval ? 1 : 0,
json(payload.publisherIds, []),
json(payload.approverIds, []),
payload.visibility,
payload.groupId,
now(),
connectionId
);
return getGraphConnection(connectionId, userId);
}
export function deleteGraphConnection(connectionId, userId) {
db.prepare(`DELETE FROM graph_connections WHERE id = ? AND ${visibleClause()}`).run(connectionId, userId, userId);
}
// Immutable record of every tenant-changing Graph action (publish, assign,
// relationship edits). Stores the actor, request payload, and Graph response so
// changes are auditable and replayable.
export function recordGraphAudit(entry) {
const auditId = id('gaud');
db.prepare(`
INSERT INTO graph_audit_log (
id, connection_id, deployment_id, actor_user_id, actor_email, action,
target_graph_id, status, message, request_json, response_json, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
auditId,
entry.connectionId || null,
entry.deploymentId || null,
entry.actorUserId || null,
entry.actorEmail || '',
entry.action,
entry.targetGraphId || '',
entry.status,
entry.message || '',
json(entry.request || {}, {}),
json(entry.response || {}, {}),
now()
);
return auditId;
}
export function listGraphAudit({ deploymentId, connectionId, limit = 100 } = {}) {
const clauses = [];
const params = [];
if (deploymentId) { clauses.push('deployment_id = ?'); params.push(deploymentId); }
if (connectionId) { clauses.push('connection_id = ?'); params.push(connectionId); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
return db.prepare(`
SELECT * FROM graph_audit_log ${where} ORDER BY created_at DESC LIMIT ?
`).all(...params, Number(limit) || 100).map((row) => ({
id: row.id,
connectionId: row.connection_id,
deploymentId: row.deployment_id,
actorUserId: row.actor_user_id,
actorEmail: row.actor_email || '',
action: row.action,
targetGraphId: row.target_graph_id || '',
status: row.status,
message: row.message || '',
request: parseJson(row.request_json, {}),
response: parseJson(row.response_json, {}),
createdAt: row.created_at
}));
}
// Read just the governance lists for a connection, without visibility scoping,
// so designated approvers (who may not "own" the connection) can be authorized.
// Full connection row + decrypted secret without visibility scope, for the
// background auto-promote worker (system context).
export function getGraphConnectionSystem(connectionId) {
const row = db.prepare('SELECT * FROM graph_connections WHERE id = ?').get(connectionId);
if (!row) return null;
return { ...row, clientSecret: decryptSecret(row) };
}
export function getConnectionGovernance(connectionId) {
const row = db.prepare('SELECT publisher_ids, approver_ids, require_approval FROM graph_connections WHERE id = ?').get(connectionId);
if (!row) return null;
return {
publisherIds: parseJson(row.publisher_ids, []),
approverIds: parseJson(row.approver_ids, []),
requireApproval: Boolean(row.require_approval)
};
}
export function recordGraphConnectionTest(connectionId, status, message) {
db.prepare(`
UPDATE graph_connections
SET last_test_status = ?, last_test_message = ?, last_test_at = ?, updated_at = ?
WHERE id = ?
`).run(status, message, now(), now(), connectionId);
}
export function upsertDeploymentStatuses(deploymentId, connectionId, graphAppId, rows = []) {
const timestamp = now();
const stmt = db.prepare(`
INSERT INTO intune_deployment_statuses (
id, deployment_id, connection_id, graph_app_id, scope, principal_id, principal_name,
device_id, device_name, user_id, user_principal_name, install_state, install_state_detail,
error_code, error_description, last_sync_datetime, raw_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
db.prepare('DELETE FROM intune_deployment_statuses WHERE deployment_id = ? AND connection_id = ? AND graph_app_id = ?')
.run(deploymentId, connectionId, graphAppId);
for (const row of rows) {
stmt.run(
id('ids'),
deploymentId,
connectionId,
graphAppId,
row.scope || 'device',
row.principalId || '',
row.principalName || '',
row.deviceId || '',
row.deviceName || '',
row.userId || '',
row.userPrincipalName || '',
row.installState || 'unknown',
row.installStateDetail || '',
row.errorCode == null ? '' : String(row.errorCode),
row.errorDescription || '',
row.lastSyncDateTime || '',
json(row.raw || {}, {}),
timestamp,
timestamp
);
}
}
export function createSyncRun(deploymentId, connectionId, graphAppId) {
const runId = id('sync');
db.prepare(`
INSERT INTO intune_deployment_sync_runs (id, deployment_id, connection_id, graph_app_id, status, started_at, summary)
VALUES (?, ?, ?, ?, 'running', ?, '{}')
`).run(runId, deploymentId, connectionId, graphAppId, now());
return runId;
}
export function finishSyncRun(runId, status, message, summary = {}) {
db.prepare(`
UPDATE intune_deployment_sync_runs
SET status = ?, message = ?, summary = ?, ended_at = ?
WHERE id = ?
`).run(status, message || '', json(summary, {}), now(), runId);
}
export function listDeploymentStatuses(deploymentId) {
return db.prepare(`
SELECT * FROM intune_deployment_statuses
WHERE deployment_id = ?
ORDER BY
CASE install_state WHEN 'failed' THEN 0 WHEN 'error' THEN 1 WHEN 'pending' THEN 2 WHEN 'installed' THEN 3 ELSE 4 END,
updated_at DESC
`).all(deploymentId).map(camelIntuneDeploymentStatus);
}
// Cross-deployment rollout health for the governance/reporting view. Scoped to
// the deployments the operator can see (admins see all).
export function intuneReportingOverview(userId, isAdmin = false) {
const scope = isAdmin ? '1=1' : visibleClause('d');
const params = isAdmin ? [] : [userId, userId];
const deploymentsByStatus = db.prepare(`
SELECT status, COUNT(*) AS count FROM intune_deployments d WHERE ${scope} GROUP BY status
`).all(...params);
const installStateCounts = db.prepare(`
SELECT s.install_state AS state, COUNT(*) AS count
FROM intune_deployment_statuses s
JOIN intune_deployments d ON d.id = s.deployment_id
WHERE ${scope}
GROUP BY s.install_state
`).all(...params);
const topErrors = db.prepare(`
SELECT s.error_code AS code, COUNT(*) AS count
FROM intune_deployment_statuses s
JOIN intune_deployments d ON d.id = s.deployment_id
WHERE ${scope} AND s.error_code IS NOT NULL AND s.error_code <> ''
GROUP BY s.error_code
ORDER BY count DESC
LIMIT 10
`).all(...params);
const writeActions = db.prepare(`
SELECT a.action, a.status, COUNT(*) AS count
FROM graph_audit_log a
JOIN intune_deployments d ON d.id = a.deployment_id
WHERE ${scope}
GROUP BY a.action, a.status
`).all(...params);
// SLA / soak timers: deployments mid-soak, with hours remaining until the
// auto-promote window elapses (negative = overdue for promotion).
const soakTimers = db.prepare(`
SELECT d.id, d.name, d.auto_promote_ring AS ring, d.auto_promote_after_hours AS hours, d.soak_started_at AS soakStartedAt,
ROUND(d.auto_promote_after_hours - (julianday('now') - julianday(d.soak_started_at)) * 24, 1) AS hoursRemaining
FROM intune_deployments d
WHERE d.auto_promote_after_hours > 0 AND d.soak_started_at IS NOT NULL AND d.soak_started_at <> ''
AND (d.promoted_at IS NULL OR d.promoted_at = '') AND ${scope}
ORDER BY hoursRemaining ASC
`).all(...params);
// Write-action trend over the last 14 days, by day and outcome.
const auditTrend = db.prepare(`
SELECT substr(a.created_at, 1, 10) AS day, a.status, COUNT(*) AS count
FROM graph_audit_log a
JOIN intune_deployments d ON d.id = a.deployment_id
WHERE ${scope} AND a.created_at >= date('now', '-14 days')
GROUP BY day, a.status
ORDER BY day
`).all(...params);
return { deploymentsByStatus, installStateCounts, topErrors, writeActions, soakTimers, auditTrend };
}
export function listDeploymentSyncRuns(deploymentId) {
return db.prepare(`
SELECT * FROM intune_deployment_sync_runs
WHERE deployment_id = ?
ORDER BY started_at DESC
LIMIT 20
`).all(deploymentId).map(camelIntuneDeploymentSyncRun);
}