-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathems-dangling-references.js
More file actions
518 lines (466 loc) · 22 KB
/
Copy pathems-dangling-references.js
File metadata and controls
518 lines (466 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
/* This script finds references to things that no longer exist in the Platform Manager (EMS) JSON stores:
ems-sites, ems-device-types, ems-devices and ems-global-settings.
Tested against Mango 5.7.5.
Part 1, deleted roles. Deleting a role does not remove its xid from these stores, and the editors cannot
remove an xid that no longer resolves to a role, so the ghost xid stays visible in the Site, Device,
Device Type and General Overview cards forever. The script reports these and, with dryRun turned off,
removes them. That grants and revokes nothing: a role xid that does not resolve is already dropped when
the permission is evaluated (EmsService.hasPermission), so it meant nothing before or after. Read the
"becomes empty" warnings though - an array pruned to nothing is superadmin-only from then on, which is
what it already was in practice, but it is the one thing worth re-assigning afterwards.
Part 2, every other dangling reference - sites, device types, devices, watch lists and custom pages.
These are reported only, never changed. Unlike a dead role xid they change what the UI shows: an orphaned
device disappears from its site, a device type whose sites are all gone is hidden from non-superadmins,
and a dead watch list or page xid renders an empty tab. The right repair - repoint, delete, or recreate
the missing thing - is a judgement call per entity, so the script only says where they are.
Data point references are out of scope. Everything above is a broken pointer between two things Mango
gives an xid to, so "it is not there" is a fact. Points are referenced by name and matched per device by
tag, so it is not: a name that exists nowhere may simply not be provisioned yet, and a name that exists
somewhere may still not resolve for a given device. That is per-device coverage, and wants its own report.
Take a configuration export with "JSON Data" selected before turning dryRun off - that is the restore
path. Re-running is always safe; the second run finds nothing left to do.
The following parameters are configurable:
- dryRun: true to report only, false to remove the dangling role xids found by part 1
- auditOtherReferences: false to skip part 2
*/
const dryRun = true;
const auditOtherReferences = true;
const SITES_XID = 'ems-sites';
const DEVICE_TYPES_XID = 'ems-device-types';
const DEVICES_XID = 'ems-devices';
const GLOBAL_SETTINGS_XID = 'ems-global-settings';
const PAGES_XID = 'mangoUI-pages';
/** The three entity stores are a map of entity xid to entity. ems-global-settings is a map of category to document. */
const ENTITY_STORE_XIDS = [SITES_XID, DEVICE_TYPES_XID, DEVICES_XID];
const STORE_XIDS = [SITES_XID, DEVICE_TYPES_XID, DEVICES_XID, GLOBAL_SETTINGS_XID];
/** "readPermissions", or a per-card field such as "kpisOnTopReadPermissions". Never "readPermission" (singular),
* which is Mango's own array-of-minterms permission shape and has nothing to do with these role xid lists. */
const PERMISSION_FIELD = /^(?:readPermissions|[A-Za-z0-9_]+ReadPermissions)$/;
/**
* Subtrees the walk never enters. A tab's "options" is an arbitrary blob the operator types into an ace
* editor (chart settings, component configuration - see ems/web-src/.../tabEditor/tabEditor.html), so a
* "readPermissions" key inside it is somebody's own data and has nothing to do with EMS role permissions.
*/
const IGNORED_KEYS = ['options'];
const Common = Java.type('com.serotonin.m2m2.Common');
const jsonDataService = services.jsonDataService;
const permissionService = services.permissionService;
// -------------------------------------------------------------------------------------- pointers, walking
/** RFC 6901: ~ then / , in that order. */
function escapeToken(token) {
return String(token).split('~').join('~0').split('/').join('~1');
}
function unescapeToken(token) {
return String(token).split('~1').join('/').split('~0').join('~');
}
/** First pointer segment - the entity xid in the three entity stores, the category in ems-global-settings. */
function ownerOf(pointer) {
const parts = pointer.split('/');
return parts.length > 1 ? unescapeToken(parts[1]) : '(root)';
}
/**
* Depth-first walk over a parsed document. visit(pointer, key, value) is called for every property; return
* true from it to stop the walk descending into that value.
*/
function walk(node, pointer, visit) {
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) {
if (!visit(pointer + '/' + i, i, node[i])) {
walk(node[i], pointer + '/' + i, visit);
}
}
} else if (node !== null && typeof node === 'object') {
for (const key of Object.keys(node)) {
if (IGNORED_KEYS.includes(key)) {
continue;
}
const childPointer = pointer + '/' + escapeToken(key);
if (!visit(childPointer, key, node[key])) {
walk(node[key], childPointer, visit);
}
}
}
}
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
// ------------------------------------------------------------------------------------------ reading stores
/** storeXid -> parsed document, or null when the store is absent/unreadable/malformed. */
const documents = {};
const storeStatus = {};
for (const storeXid of STORE_XIDS) {
try {
const parsed = JSON.parse(String(jsonDataService.getDataAtPointer(storeXid, '')));
if (!isObject(parsed)) {
documents[storeXid] = null;
storeStatus[storeXid] = 'malformed: document root is not an object';
} else {
documents[storeXid] = parsed;
storeStatus[storeXid] = 'ok';
}
} catch (e) {
documents[storeXid] = null;
storeStatus[storeXid] = 'could not be read: ' + e;
}
}
// ================================================================ PART 1 - deleted role references
const roleExists = new Map();
/**
* Authoritative existence check - the same lookup EmsService uses to decide whether a listed xid means
* anything. It is a loading cache over the roles table, so a role missing from it is genuinely gone.
*/
function isDanglingRole(xid) {
if (!roleExists.has(xid)) {
roleExists.set(xid, permissionService.getRole(xid) !== null);
}
return !roleExists.get(xid);
}
const roleReport = [];
const roleWarnings = [];
let totalArrays = 0;
let totalReferences = 0;
let totalEmptied = 0;
for (const storeXid of STORE_XIDS) {
const entry = { storeXid: storeXid, status: storeStatus[storeXid], hits: [] };
roleReport.push(entry);
const document = documents[storeXid];
if (document === null) {
continue;
}
// Rather than hard coding the seven known locations, treat every "readPermissions" / "<name>ReadPermissions"
// array of strings as a list of role xids - so locations added later are covered too.
walk(document, '', function (pointer, key, value) {
if (!PERMISSION_FIELD.test(String(key)) || !Array.isArray(value)) {
return false;
}
const kept = [];
const removed = [];
const removedCounts = new Map();
let nonStrings = 0;
for (const element of value) {
if (typeof element !== 'string') {
nonStrings++;
kept.push(element);
} else if (isDanglingRole(element)) {
removed.push(element);
removedCounts.set(element, (removedCounts.get(element) || 0) + 1);
} else {
kept.push(element);
}
}
if (nonStrings > 0) {
roleWarnings.push('! ' + storeXid + ' ' + pointer + ' holds ' + nonStrings
+ ' non-string element(s), left untouched');
}
if (removed.length > 0) {
totalArrays++;
totalReferences += removed.length;
if (kept.length === 0) {
totalEmptied++;
roleWarnings.push('! ' + storeXid + ' ' + pointer + ' becomes empty - visible to superadmins '
+ 'only from now on (it already was in practice). Re-assign a role if it should stay visible.');
}
entry.hits.push({
pointer: pointer,
owner: ownerOf(pointer),
original: value,
kept: kept,
removed: removed,
// one entry per dead xid, "(x2)" where the same one was listed twice
names: [...removedCounts].map(([xid, count]) => (count > 1 ? xid + ' (x' + count + ')' : xid))
});
}
return true; // a permissions array holds strings, nothing to descend into
});
}
// ================================================================ PART 2 - every other dangling reference
const audit = [];
const auditNotes = [];
/** Every finding is addressed the same way part 1 addresses its arrays: store plus a JSON pointer. */
function finding(category, storeXid, pointer, message) {
audit.push({ category: category, storeXid: storeXid, pointer: pointer, message: message });
}
/**
* The identity of an entity is its embedded xid - that is what EmsService resolves a Device's siteXid and a
* Device Type's sites against, not the map key (see EmsService#visibleSiteXids). An entity saved without one
* falls back to its key so it is not reported as missing everywhere at once; the mismatch itself is reported
* separately below.
*/
function identitySet(storeXid) {
const found = new Set();
const document = documents[storeXid];
if (document === null) return found;
for (const key of Object.keys(document)) {
const entity = document[key];
const embedded = isObject(entity) && typeof entity.xid === 'string' ? entity.xid : null;
found.add(embedded !== null ? embedded : key);
}
return found;
}
/** Runs fn(entityKey, entity) over an entity store, or over the categories of ems-global-settings. */
function forEachEntity(storeXid, fn) {
const document = documents[storeXid];
if (document === null) return;
for (const key of Object.keys(document)) {
if (isObject(document[key])) {
fn(key, document[key]);
}
}
}
/** Runs fn(pointer, tab) over every layout.grid.items[].tabs[] entry of an entity or settings category. */
function forEachTab(entity, basePointer, fn) {
if (!isObject(entity) || !isObject(entity.layout) || !isObject(entity.layout.grid)
|| !Array.isArray(entity.layout.grid.items)) {
return;
}
const items = entity.layout.grid.items;
for (let i = 0; i < items.length; i++) {
if (!isObject(items[i]) || !Array.isArray(items[i].tabs)) continue;
for (let t = 0; t < items[i].tabs.length; t++) {
if (isObject(items[i].tabs[t])) {
fn(basePointer + '/layout/grid/items/' + i + '/tabs/' + t, items[i].tabs[t]);
}
}
}
}
if (auditOtherReferences) {
const siteIds = identitySet(SITES_XID);
const deviceTypeIds = identitySet(DEVICE_TYPES_XID);
// --- entity key vs embedded xid. EmsService keys visibility off the embedded xid, so a mismatch makes an
// entity unreachable by everything that refers to it.
for (const storeXid of ENTITY_STORE_XIDS) {
forEachEntity(storeXid, function (key, entity) {
if (typeof entity.xid !== 'string') {
finding('entity identity', storeXid, '/' + escapeToken(key), "has no embedded 'xid'");
} else if (entity.xid !== key) {
finding('entity identity', storeXid, '/' + escapeToken(key) + '/xid',
"'" + entity.xid + "' - does not match the key it is stored under");
}
});
}
// --- Device.siteXid and Device.deviceTypeXid
forEachEntity(DEVICES_XID, function (key, device) {
if (typeof device.siteXid !== 'string' || device.siteXid === '') {
finding('device -> site', DEVICES_XID, '/' + escapeToken(key) + '/siteXid',
'missing or empty - the device belongs to no site');
} else if (!siteIds.has(device.siteXid)) {
finding('device -> site', DEVICES_XID, '/' + escapeToken(key) + '/siteXid',
"'" + device.siteXid + "' - no such site");
}
if (typeof device.deviceTypeXid === 'string' && device.deviceTypeXid !== ''
&& !deviceTypeIds.has(device.deviceTypeXid)) {
finding('device -> device type', DEVICES_XID, '/' + escapeToken(key) + '/deviceTypeXid',
"'" + device.deviceTypeXid + "' - no such device type");
}
});
// --- DeviceType.sites[]. A device type whose sites are all gone is hidden from every non-superadmin
// (EmsService#canReadViaSites fails closed on an empty or unresolvable sites array).
forEachEntity(DEVICE_TYPES_XID, function (key, deviceType) {
const sitesPointer = '/' + escapeToken(key) + '/sites';
if (!Array.isArray(deviceType.sites)) {
finding('device type -> sites', DEVICE_TYPES_XID, sitesPointer,
'not an array - hidden from non-superadmins');
return;
}
if (deviceType.sites.length === 0) {
finding('device type -> sites', DEVICE_TYPES_XID, sitesPointer,
'empty - hidden from non-superadmins');
return;
}
let live = 0;
for (let i = 0; i < deviceType.sites.length; i++) {
const site = deviceType.sites[i];
if (typeof site !== 'string') {
finding('device type -> sites', DEVICE_TYPES_XID, sitesPointer + '/' + i, 'not a string');
} else if (siteIds.has(site)) {
live++;
} else {
finding('device type -> sites', DEVICE_TYPES_XID, sitesPointer + '/' + i,
"'" + site + "' - no such site");
}
}
if (live === 0) {
finding('device type -> sites', DEVICE_TYPES_XID, sitesPointer,
'no site it names still exists - hidden from non-superadmins');
}
});
// --- Site column -> device. A site column of type POINT stores the device's tagValue in deviceID.
const deviceTagValues = new Set();
forEachEntity(DEVICES_XID, function (key, device) {
if (typeof device.tagValue === 'string') deviceTagValues.add(device.tagValue);
});
forEachEntity(SITES_XID, function (key, site) {
walk(site, '/' + escapeToken(key), function (pointer, k, value) {
if (k === 'deviceID' && typeof value === 'string' && value !== '' && !deviceTagValues.has(value)) {
finding('column -> device', SITES_XID, pointer,
"'" + value + "' - no device has that tag value");
}
return false;
});
});
// --- WATCHLIST tabs -> watch lists, DASHBOARD tabs -> custom pages.
const watchListService = services.watchListService;
if (!watchListService) {
auditNotes.push('the Watchlist module is not installed, so watch list references were not checked');
}
const watchListExists = new Map();
function isDanglingWatchList(xid) {
if (!watchListExists.has(xid)) {
let exists;
try {
exists = watchListService.get(xid) !== null;
} catch (e) {
exists = false; // NotFoundException
}
watchListExists.set(xid, exists);
}
return !watchListExists.get(xid);
}
let pageIds = null;
try {
const pages = JSON.parse(String(jsonDataService.getDataAtPointer(PAGES_XID, '')));
if (isObject(pages) && Array.isArray(pages.pages)) {
pageIds = new Set();
for (const page of pages.pages) {
if (isObject(page) && typeof page.xid === 'string') pageIds.add(page.xid);
}
}
} catch (e) {
auditNotes.push('the ' + PAGES_XID + ' store is absent, so DASHBOARD tab references were not checked');
}
function auditTabs(storeXid, entity, basePointer) {
forEachTab(entity, basePointer, function (pointer, tab) {
if (tab.type === 'WATCHLIST' && watchListService && Array.isArray(tab.items)) {
for (let i = 0; i < tab.items.length; i++) {
const item = tab.items[i];
if (isObject(item) && typeof item.xid === 'string' && item.xid !== ''
&& isDanglingWatchList(item.xid)) {
finding('tab -> watch list', storeXid, pointer + '/items/' + i + '/xid',
"'" + item.xid + "' - no such watch list");
}
}
}
if (tab.type === 'DASHBOARD' && pageIds !== null && typeof tab.xid === 'string' && tab.xid !== ''
&& !pageIds.has(tab.xid)) {
finding('tab -> custom page', storeXid, pointer + '/xid',
"'" + tab.xid + "' - no such page in " + PAGES_XID);
}
});
}
for (const storeXid of [SITES_XID, DEVICE_TYPES_XID, GLOBAL_SETTINGS_XID]) {
forEachEntity(storeXid, function (key, entity) {
auditTabs(storeXid, entity, '/' + escapeToken(key));
});
}
}
// ---------------------------------------------------------------------------------------------- report
try {
if (typeof response !== 'undefined' && response !== null) {
response.setContentType('text/plain');
}
} catch (ignored) {
// output is a download rather than inline text, not worth failing over
}
console.log('EMS dangling reference report');
console.log('mode: ' + (dryRun ? 'DRY RUN - nothing will be written' : 'APPLY - dangling role xids will be removed'));
console.log('run by: ' + Common.getUser().getPermissionHolderName());
console.log('');
console.log('PART 1 - DELETED ROLE REFERENCES (removed by this script)');
console.log('');
for (const entry of roleReport) {
if (entry.status !== 'ok') {
console.log('[' + entry.storeXid + '] ' + entry.status);
continue;
}
if (entry.hits.length === 0) {
console.log('[' + entry.storeXid + '] no dangling role references');
continue;
}
console.log('[' + entry.storeXid + ']');
let owner = null;
for (const hit of entry.hits) {
if (hit.owner !== owner) {
owner = hit.owner;
console.log(' ' + owner);
}
console.log(' ' + hit.pointer.padEnd(58) + ' remove: ' + hit.names.join(', ')
+ (hit.kept.length === 0 ? ' ** BECOMES EMPTY **' : ''));
}
}
if (roleWarnings.length > 0) {
console.log('');
console.log(' WARNINGS');
for (const warning of roleWarnings) {
console.log(' ' + warning);
}
}
console.log('');
console.log(' arrays to change: ' + totalArrays + ' references to remove: ' + totalReferences
+ ' arrays left empty: ' + totalEmptied);
console.log('');
console.log('PART 2 - OTHER DANGLING REFERENCES (reported only, nothing is changed)');
console.log('');
if (!auditOtherReferences) {
console.log(' skipped - auditOtherReferences is false');
} else if (audit.length === 0) {
console.log(' none found');
} else {
// findings are collected store by store, so group them here - a category must appear once
const categories = [...new Set(audit.map(item => item.category))];
for (const category of categories) {
console.log(' [' + category + ']');
for (const item of audit) {
if (item.category === category) {
console.log(' ' + (item.storeXid + ' ' + item.pointer).padEnd(62) + ' ' + item.message);
}
}
}
console.log('');
console.log(' ' + audit.length + ' finding(s). Each needs a decision - repoint it, delete the entity, or');
console.log(' recreate what is missing - so this script does not touch them.');
}
if (auditNotes.length > 0) {
console.log('');
console.log(' NOTES');
for (const note of auditNotes) {
console.log(' - ' + note);
}
}
// ----------------------------------------------------------------------------------------------- apply
console.log('');
if (totalArrays === 0) {
console.log('No role references to remove.');
} else if (dryRun) {
console.log('DRY RUN - nothing was written. Set dryRun = false at the top of the script and run it again.');
} else {
console.log('WRITING');
let written = 0;
let skipped = 0;
for (const entry of roleReport) {
for (const hit of entry.hits) {
try {
// One locked read-modify-write per array, so a Platform Manager edit made to some other
// entity while this runs is not clobbered. Re-read first: if this array changed since it was
// inspected, leave it alone rather than overwrite someone's edit with a stale value.
const current = JSON.parse(String(jsonDataService.getDataAtPointer(entry.storeXid, hit.pointer)));
if (JSON.stringify(current) !== JSON.stringify(hit.original)) {
skipped++;
console.log(' SKIPPED ' + entry.storeXid + ' ' + hit.pointer + ' - changed since it was read, re-run');
continue;
}
// a JS string picks the (String, String, String) overload; if a future signature ever makes
// that ambiguous the explicit form is
// jsonDataService['setDataAtPointer(java.lang.String,java.lang.String,java.lang.String)'](...)
jsonDataService.setDataAtPointer(entry.storeXid, hit.pointer, JSON.stringify(hit.kept));
written++;
console.log(' updated ' + entry.storeXid + ' ' + hit.pointer);
} catch (e) {
skipped++;
console.log(' FAILED ' + entry.storeXid + ' ' + hit.pointer + ' - ' + e);
}
}
}
console.log('');
console.log(' arrays written: ' + written + ' skipped: ' + skipped);
console.log(' Every write is recorded as a JSON_DATA audit event. Run again to confirm nothing is left.');
}