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