fastapi-rbac-authz 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1879 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>RBAC Authorization Visualization</title>
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg-primary: #0d1117;
11
+ --bg-secondary: #161b22;
12
+ --bg-elevated: #21262d;
13
+ --color-role: #58a6ff;
14
+ --color-permission: #3fb950;
15
+ --color-context: #d29922;
16
+ --color-endpoint: #a371f7;
17
+ --color-tag: #6e7681;
18
+ --text-primary: #e6edf3;
19
+ --text-secondary: #8b949e;
20
+ --border-primary: #30363d;
21
+ --accent: #f778ba;
22
+ }
23
+
24
+ * {
25
+ margin: 0;
26
+ padding: 0;
27
+ box-sizing: border-box;
28
+ }
29
+
30
+ body {
31
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
32
+ background: linear-gradient(135deg, var(--bg-primary) 0%, #0a0f14 100%);
33
+ color: var(--text-primary);
34
+ height: 100vh;
35
+ overflow: hidden;
36
+ }
37
+
38
+ .container {
39
+ display: flex;
40
+ height: 100vh;
41
+ }
42
+
43
+ #cy {
44
+ flex: 1;
45
+ background: radial-gradient(ellipse at center, var(--bg-secondary) 0%, var(--bg-primary) 100%);
46
+ position: relative;
47
+ }
48
+
49
+ /* Legend Overlay */
50
+ .legend-overlay {
51
+ position: absolute;
52
+ top: 20px;
53
+ left: 20px;
54
+ background: rgba(22, 27, 34, 0.95);
55
+ backdrop-filter: blur(10px);
56
+ border-radius: 12px;
57
+ padding: 15px;
58
+ z-index: 1000;
59
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
60
+ border: 1px solid rgba(255, 255, 255, 0.1);
61
+ min-width: 180px;
62
+ transition: all 0.3s ease;
63
+ }
64
+
65
+ .legend-header {
66
+ display: flex;
67
+ justify-content: space-between;
68
+ align-items: center;
69
+ margin-bottom: 12px;
70
+ }
71
+
72
+ .legend-header h3 {
73
+ font-size: 0.85rem;
74
+ color: var(--text-secondary);
75
+ text-transform: uppercase;
76
+ letter-spacing: 0.5px;
77
+ margin: 0;
78
+ }
79
+
80
+ .legend-toggle {
81
+ background: transparent;
82
+ border: 1px solid var(--border-primary);
83
+ color: var(--text-secondary);
84
+ width: 24px;
85
+ height: 24px;
86
+ border-radius: 6px;
87
+ cursor: pointer;
88
+ font-size: 14px;
89
+ line-height: 1;
90
+ transition: all 0.2s ease;
91
+ }
92
+
93
+ .legend-toggle:hover {
94
+ background: var(--bg-elevated);
95
+ color: var(--text-primary);
96
+ }
97
+
98
+ .legend-content {
99
+ transition: all 0.3s ease;
100
+ overflow: hidden;
101
+ }
102
+
103
+ .legend-content.collapsed {
104
+ max-height: 0;
105
+ opacity: 0;
106
+ margin-top: 0;
107
+ }
108
+
109
+ .legend-section {
110
+ margin-bottom: 12px;
111
+ }
112
+
113
+ .legend-section:last-child {
114
+ margin-bottom: 0;
115
+ }
116
+
117
+ .legend-section-title {
118
+ font-size: 0.7rem;
119
+ color: var(--text-secondary);
120
+ margin-bottom: 6px;
121
+ opacity: 0.7;
122
+ }
123
+
124
+ .legend-item {
125
+ display: flex;
126
+ align-items: center;
127
+ margin-bottom: 6px;
128
+ font-size: 0.8rem;
129
+ }
130
+
131
+ .legend-color {
132
+ width: 14px;
133
+ height: 14px;
134
+ border-radius: 50%;
135
+ margin-right: 10px;
136
+ box-shadow: 0 0 8px currentColor;
137
+ }
138
+
139
+ .legend-color.role { background: var(--color-role); color: var(--color-role); }
140
+ .legend-color.permission { background: var(--color-permission); color: var(--color-permission); }
141
+ .legend-color.context { background: var(--color-context); color: var(--color-context); }
142
+ .legend-color.endpoint { background: var(--color-endpoint); color: var(--color-endpoint); }
143
+
144
+ .legend-line {
145
+ width: 24px;
146
+ height: 3px;
147
+ margin-right: 10px;
148
+ border-radius: 2px;
149
+ }
150
+
151
+ .legend-line.solid {
152
+ background: var(--color-role);
153
+ }
154
+
155
+ .legend-line.dashed {
156
+ background: repeating-linear-gradient(
157
+ 90deg,
158
+ var(--color-context) 0px,
159
+ var(--color-context) 6px,
160
+ transparent 6px,
161
+ transparent 10px
162
+ );
163
+ }
164
+
165
+ .legend-line.dotted {
166
+ background: repeating-linear-gradient(
167
+ 90deg,
168
+ var(--color-endpoint) 0px,
169
+ var(--color-endpoint) 3px,
170
+ transparent 3px,
171
+ transparent 6px
172
+ );
173
+ }
174
+
175
+ .sidebar {
176
+ width: 350px;
177
+ background: rgba(22, 27, 34, 0.95);
178
+ backdrop-filter: blur(20px);
179
+ padding: 20px;
180
+ overflow-y: auto;
181
+ border-left: 1px solid var(--border-primary);
182
+ }
183
+
184
+ .search-box {
185
+ width: 100%;
186
+ padding: 12px 16px;
187
+ border: 1px solid var(--border-primary);
188
+ border-radius: 8px;
189
+ background: var(--bg-primary);
190
+ color: var(--text-primary);
191
+ font-size: 14px;
192
+ margin-bottom: 15px;
193
+ transition: all 0.2s ease;
194
+ }
195
+
196
+ .search-box:focus {
197
+ outline: none;
198
+ border-color: var(--color-role);
199
+ box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.2);
200
+ }
201
+
202
+ .search-box::placeholder {
203
+ color: var(--text-secondary);
204
+ }
205
+
206
+ .details-panel {
207
+ margin-top: 20px;
208
+ padding: 16px;
209
+ background: var(--bg-primary);
210
+ border-radius: 12px;
211
+ border: 1px solid var(--border-primary);
212
+ min-height: 200px;
213
+ }
214
+
215
+ .details-panel h2 {
216
+ font-size: 0.9rem;
217
+ margin-bottom: 15px;
218
+ color: var(--accent);
219
+ }
220
+
221
+ .details-panel .empty {
222
+ color: var(--text-secondary);
223
+ font-style: italic;
224
+ }
225
+
226
+ .detail-item {
227
+ margin-bottom: 14px;
228
+ }
229
+
230
+ .detail-label {
231
+ font-size: 0.7rem;
232
+ color: var(--text-secondary);
233
+ text-transform: uppercase;
234
+ letter-spacing: 0.5px;
235
+ margin-bottom: 5px;
236
+ }
237
+
238
+ .detail-value {
239
+ font-size: 0.9rem;
240
+ word-break: break-word;
241
+ }
242
+
243
+ .badge {
244
+ display: inline-block;
245
+ padding: 4px 10px;
246
+ border-radius: 20px;
247
+ font-size: 11px;
248
+ margin: 3px;
249
+ font-weight: 500;
250
+ border: 1px solid;
251
+ }
252
+
253
+ .badge.global {
254
+ background: rgba(88, 166, 255, 0.15);
255
+ color: var(--color-role);
256
+ border-color: var(--color-role);
257
+ }
258
+
259
+ .badge.contextual {
260
+ background: rgba(210, 153, 34, 0.15);
261
+ color: var(--color-context);
262
+ border-color: var(--color-context);
263
+ }
264
+
265
+ .badge.permission {
266
+ background: rgba(63, 185, 80, 0.15);
267
+ color: var(--color-permission);
268
+ border-color: var(--color-permission);
269
+ }
270
+
271
+ .badge.context {
272
+ background: rgba(210, 153, 34, 0.15);
273
+ color: var(--color-context);
274
+ border-color: var(--color-context);
275
+ }
276
+
277
+ .loading {
278
+ display: flex;
279
+ justify-content: center;
280
+ align-items: center;
281
+ height: 100%;
282
+ font-size: 1.2rem;
283
+ color: var(--text-secondary);
284
+ }
285
+
286
+ .error {
287
+ color: var(--accent);
288
+ padding: 20px;
289
+ text-align: center;
290
+ }
291
+
292
+ /* Scrollbar styling */
293
+ ::-webkit-scrollbar {
294
+ width: 8px;
295
+ }
296
+
297
+ ::-webkit-scrollbar-track {
298
+ background: var(--bg-primary);
299
+ }
300
+
301
+ ::-webkit-scrollbar-thumb {
302
+ background: var(--border-primary);
303
+ border-radius: 4px;
304
+ }
305
+
306
+ ::-webkit-scrollbar-thumb:hover {
307
+ background: var(--text-secondary);
308
+ }
309
+
310
+ .reset-view-btn {
311
+ position: absolute;
312
+ top: 20px;
313
+ right: 20px;
314
+ background: rgba(247, 120, 186, 0.9);
315
+ color: #fff;
316
+ border: none;
317
+ padding: 10px 20px;
318
+ border-radius: 8px;
319
+ font-size: 14px;
320
+ font-weight: 500;
321
+ cursor: pointer;
322
+ z-index: 1000;
323
+ box-shadow: 0 4px 12px rgba(247, 120, 186, 0.4);
324
+ transition: all 0.2s ease;
325
+ }
326
+
327
+ .reset-view-btn:hover {
328
+ background: rgba(247, 120, 186, 1);
329
+ transform: translateY(-1px);
330
+ }
331
+
332
+ .show-more-link {
333
+ display: inline-block;
334
+ margin-top: 8px;
335
+ color: var(--color-role);
336
+ font-size: 0.8rem;
337
+ text-decoration: none;
338
+ cursor: pointer;
339
+ }
340
+
341
+ .show-more-link:hover {
342
+ text-decoration: underline;
343
+ }
344
+
345
+ .badge.clickable {
346
+ cursor: pointer;
347
+ transition: all 0.15s ease;
348
+ }
349
+
350
+ .badge.clickable:hover {
351
+ filter: brightness(1.3);
352
+ transform: scale(1.05);
353
+ }
354
+
355
+ .context-description {
356
+ white-space: pre-wrap;
357
+ max-height: 150px;
358
+ overflow-y: auto;
359
+ font-size: 0.85rem;
360
+ line-height: 1.4;
361
+ background: var(--bg-elevated);
362
+ padding: 8px;
363
+ border-radius: 6px;
364
+ margin-top: 4px;
365
+ }
366
+
367
+ .clickable-endpoint {
368
+ display: block;
369
+ margin-bottom: 4px;
370
+ padding: 4px 8px;
371
+ background: rgba(163, 113, 247, 0.15);
372
+ border: 1px solid var(--color-endpoint);
373
+ color: var(--color-endpoint);
374
+ border-radius: 4px;
375
+ cursor: pointer;
376
+ font-size: 0.85rem;
377
+ transition: all 0.15s ease;
378
+ }
379
+
380
+ .clickable-endpoint:hover {
381
+ background: rgba(163, 113, 247, 0.3);
382
+ }
383
+ </style>
384
+ </head>
385
+ <body>
386
+ <div class="container">
387
+ <div id="cy">
388
+ <div class="loading">Loading RBAC schema...</div>
389
+ <div class="legend-overlay" id="legend-overlay">
390
+ <div class="legend-header">
391
+ <h3>Legend</h3>
392
+ <button class="legend-toggle" id="legend-toggle" onclick="toggleLegend()">−</button>
393
+ </div>
394
+ <div class="legend-content" id="legend-content">
395
+ <div class="legend-section">
396
+ <div class="legend-section-title">Node Types</div>
397
+ <div class="legend-item"><div class="legend-color role"></div><span>Roles (<span id="stat-roles">0</span>)</span></div>
398
+ <div class="legend-item"><div class="legend-color permission"></div><span>Permissions (<span id="stat-permissions">0</span>)</span></div>
399
+ <div class="legend-item"><div class="legend-color endpoint"></div><span>Endpoints (<span id="stat-endpoints">0</span>)</span></div>
400
+ <div class="legend-item"><div class="legend-color context"></div><span>Contexts (<span id="stat-contexts">0</span>)</span></div>
401
+ </div>
402
+ <div class="legend-section">
403
+ <div class="legend-section-title">Edge Types</div>
404
+ <div class="legend-item"><div class="legend-line solid"></div><span>Global (direct to endpoint)</span></div>
405
+ <div class="legend-item"><div class="legend-line dashed"></div><span>Contextual (via context)</span></div>
406
+ <div class="legend-item"><div class="legend-line dotted"></div><span>Wildcard implies</span></div>
407
+ </div>
408
+ <div class="legend-section">
409
+ <div class="legend-section-title">Features</div>
410
+ <div class="legend-item"><div class="legend-line solid"></div><span>Double-click any node for a deep dive</span></div>
411
+ </div>
412
+ </div>
413
+ </div>
414
+ <button id="reset-view-btn" class="reset-view-btn" style="display: none;">
415
+ Reset View
416
+ </button>
417
+ </div>
418
+ <div class="sidebar">
419
+ <input type="text" class="search-box" id="search" placeholder="Search nodes...">
420
+
421
+ <div class="details-panel">
422
+ <h2>Node Details</h2>
423
+ <div id="details-content">
424
+ <p class="empty">Click a node to see details</p>
425
+ </div>
426
+ </div>
427
+ </div>
428
+ </div>
429
+
430
+ <script>
431
+ const SCHEMA_URL = '{{SCHEMA_URL}}';
432
+
433
+ let cy = null;
434
+ let schema = null;
435
+ let resizeTimeout = null;
436
+ let selectedNode = null;
437
+ let isolationMode = false;
438
+ let savedPositions = null;
439
+
440
+ // Colors for node types (GitHub-inspired dark theme)
441
+ const colors = {
442
+ role: '#58a6ff',
443
+ permission: '#3fb950',
444
+ context: '#d29922',
445
+ endpoint: '#a371f7',
446
+ 'tag-group': '#6e7681'
447
+ };
448
+
449
+ // Wildcard permission matching
450
+ const WILDCARD = "*";
451
+ const SEPARATOR = ":";
452
+
453
+ function implies(held, required) {
454
+ const heldParts = held.split(SEPARATOR);
455
+ const requiredParts = required.split(SEPARATOR);
456
+
457
+ for (let i = 0; i < heldParts.length; i++) {
458
+ if (heldParts[i] === WILDCARD) return true;
459
+ if (i >= requiredParts.length) return false;
460
+ if (heldParts[i] !== requiredParts[i]) return false;
461
+ }
462
+ return heldParts.length === requiredParts.length;
463
+ }
464
+
465
+ function isWildcardPermission(permission) {
466
+ return permission.includes(WILDCARD);
467
+ }
468
+
469
+ // Forward path traversal (for roles)
470
+ // Follows: Role -> Permission -> Context -> Endpoint (contextual)
471
+ // Role -> Permission -> Endpoint (global direct)
472
+ function getForwardPath(node) {
473
+ const collected = cy.collection().union(node);
474
+ let frontier = cy.collection().union(node);
475
+
476
+ while (frontier.length > 0) {
477
+ // Get outgoing edges from frontier nodes
478
+ const outEdges = frontier.outgoers('edge');
479
+ collected.merge(outEdges);
480
+
481
+ // Get target nodes of those edges
482
+ const nextNodes = outEdges.targets().not(collected);
483
+ collected.merge(nextNodes);
484
+
485
+ frontier = nextNodes;
486
+ }
487
+
488
+ // Include parent nodes (tag-groups) of collected nodes
489
+ collected.nodes().forEach(n => {
490
+ const parent = n.parent();
491
+ if (parent.length > 0) {
492
+ collected.merge(parent);
493
+ }
494
+ });
495
+
496
+ return collected;
497
+ }
498
+
499
+ // Backward path traversal (for contexts)
500
+ function getBackwardPath(node) {
501
+ const collected = cy.collection().union(node);
502
+ let frontier = cy.collection().union(node);
503
+
504
+ while (frontier.length > 0) {
505
+ // Get incoming edges to frontier nodes
506
+ const inEdges = frontier.incomers('edge');
507
+ collected.merge(inEdges);
508
+
509
+ // Get source nodes of those edges
510
+ const nextNodes = inEdges.sources().not(collected);
511
+ collected.merge(nextNodes);
512
+
513
+ frontier = nextNodes;
514
+ }
515
+
516
+ // Include parent nodes (tag-groups) of collected nodes
517
+ collected.nodes().forEach(n => {
518
+ const parent = n.parent();
519
+ if (parent.length > 0) {
520
+ collected.merge(parent);
521
+ }
522
+ });
523
+
524
+ return collected;
525
+ }
526
+
527
+ // Bidirectional path traversal (for permissions/endpoints)
528
+ function getBidirectionalPath(node) {
529
+ return getForwardPath(node).union(getBackwardPath(node));
530
+ }
531
+
532
+ // Context path traversal: Permission -> Context -> Endpoint, and backward to Roles
533
+ function getContextPath(node) {
534
+ const collected = cy.collection().union(node);
535
+
536
+ // First, get forward path from context to endpoints
537
+ const contextEdges = node.outgoers('edge');
538
+ collected.merge(contextEdges);
539
+ const endpoints = contextEdges.targets();
540
+ collected.merge(endpoints);
541
+
542
+ // Get backward path from context to permissions (Permission -> Context edges)
543
+ const permContextEdges = node.incomers('edge').filter('[edgeType="permission-context"]');
544
+ collected.merge(permContextEdges);
545
+ const permissions = permContextEdges.sources();
546
+ collected.merge(permissions);
547
+
548
+ // Continue backward from permissions to roles
549
+ if (permissions.length > 0) {
550
+ let frontier = permissions;
551
+ while (frontier.length > 0) {
552
+ const inEdges = frontier.incomers('edge');
553
+ collected.merge(inEdges);
554
+ const nextNodes = inEdges.sources().not(collected);
555
+ collected.merge(nextNodes);
556
+ frontier = nextNodes;
557
+ }
558
+ }
559
+
560
+ // Also get any direct global Permission -> Endpoint edges for collected endpoints
561
+ if (endpoints.length > 0) {
562
+ const directEdges = endpoints.incomers('edge').filter('[edgeType="direct"]');
563
+ collected.merge(directEdges);
564
+ const directPerms = directEdges.sources().not(collected);
565
+ collected.merge(directPerms);
566
+
567
+ // Continue backward from those permissions to roles
568
+ let frontier = directPerms;
569
+ while (frontier.length > 0) {
570
+ const inEdges = frontier.incomers('edge');
571
+ collected.merge(inEdges);
572
+ const nextNodes = inEdges.sources().not(collected);
573
+ collected.merge(nextNodes);
574
+ frontier = nextNodes;
575
+ }
576
+ }
577
+
578
+ // Include parent nodes (tag-groups) of collected nodes
579
+ collected.nodes().forEach(n => {
580
+ const parent = n.parent();
581
+ if (parent.length > 0) {
582
+ collected.merge(parent);
583
+ }
584
+ });
585
+
586
+ return collected;
587
+ }
588
+
589
+ // Node-type-aware isolation: Role → "What endpoints can this role access?"
590
+ function isolateRole(roleNode) {
591
+ const rolePermissions = roleNode.data('details').permissions;
592
+ const related = cy.collection().add(roleNode);
593
+
594
+ // Find all permission nodes this role grants (directly or via wildcard)
595
+ cy.nodes('[type="permission"]').forEach(permNode => {
596
+ const permName = permNode.data('details').name;
597
+ if (rolePermissions.some(rp => implies(rp.permission, permName))) {
598
+ related.merge(permNode);
599
+ }
600
+ });
601
+
602
+ // Find all endpoints accessible via these permissions
603
+ cy.nodes('[type="endpoint"]').forEach(epNode => {
604
+ const epPerms = epNode.data('details').permissions;
605
+ if (epPerms.some(epPerm =>
606
+ rolePermissions.some(rp => implies(rp.permission, epPerm))
607
+ )) {
608
+ related.merge(epNode);
609
+ // Add endpoint's contexts
610
+ const contexts = epNode.data('details').contexts || [];
611
+ contexts.forEach(ctxName => {
612
+ const ctxNode = cy.getElementById(`context:${ctxName}`);
613
+ if (ctxNode.length) related.merge(ctxNode);
614
+ });
615
+ }
616
+ });
617
+
618
+ // Add connecting edges between related nodes
619
+ related.merge(related.edgesWith(related));
620
+
621
+ // Include parent nodes (tag-groups) of collected nodes
622
+ related.nodes().forEach(n => {
623
+ const parent = n.parent();
624
+ if (parent.length > 0) {
625
+ related.merge(parent);
626
+ }
627
+ });
628
+
629
+ return related;
630
+ }
631
+
632
+ // Node-type-aware isolation: Permission → "What does this permission grant access to?"
633
+ function isolatePermission(permNode) {
634
+ const permName = permNode.data('details').name;
635
+ const related = cy.collection().add(permNode);
636
+
637
+ // Find all roles that grant this permission (directly or via wildcard)
638
+ cy.nodes('[type="role"]').forEach(roleNode => {
639
+ const rolePerms = roleNode.data('details').permissions;
640
+ if (rolePerms.some(rp => implies(rp.permission, permName))) {
641
+ related.merge(roleNode);
642
+ }
643
+ });
644
+
645
+ // Find all endpoints protected by this permission
646
+ cy.nodes('[type="endpoint"]').forEach(epNode => {
647
+ const epPerms = epNode.data('details').permissions;
648
+ if (epPerms.includes(permName)) {
649
+ related.merge(epNode);
650
+ // Add endpoint's contexts
651
+ const contexts = epNode.data('details').contexts || [];
652
+ contexts.forEach(ctxName => {
653
+ const ctxNode = cy.getElementById(`context:${ctxName}`);
654
+ if (ctxNode.length) related.merge(ctxNode);
655
+ });
656
+ }
657
+ });
658
+
659
+ // Add connecting edges between related nodes
660
+ related.merge(related.edgesWith(related));
661
+
662
+ // Include parent nodes (tag-groups) of collected nodes
663
+ related.nodes().forEach(n => {
664
+ const parent = n.parent();
665
+ if (parent.length > 0) {
666
+ related.merge(parent);
667
+ }
668
+ });
669
+
670
+ return related;
671
+ }
672
+
673
+ // Node-type-aware isolation: Endpoint → "Who can access this endpoint?"
674
+ function isolateEndpoint(epNode) {
675
+ const epPerms = epNode.data('details').permissions;
676
+ const epContexts = epNode.data('details').contexts || [];
677
+ const related = cy.collection().add(epNode);
678
+
679
+ // Add permission nodes that protect this endpoint (exact match or wildcard that implies)
680
+ epPerms.forEach(requiredPerm => {
681
+ // Try exact match first
682
+ const exactNode = cy.getElementById(`permission:${requiredPerm}`);
683
+ if (exactNode.length) related.merge(exactNode);
684
+
685
+ // Also find wildcard permissions that imply this required permission
686
+ cy.nodes('[type="permission"]').forEach(permNode => {
687
+ const permName = permNode.data('details').name;
688
+ if (implies(permName, requiredPerm)) {
689
+ related.merge(permNode);
690
+ }
691
+ });
692
+ });
693
+
694
+ // Add context nodes that protect this endpoint
695
+ epContexts.forEach(ctxName => {
696
+ const ctxNode = cy.getElementById(`context:${ctxName}`);
697
+ if (ctxNode.length) related.merge(ctxNode);
698
+ });
699
+
700
+ // Find all roles that can access this endpoint
701
+ cy.nodes('[type="role"]').forEach(roleNode => {
702
+ const rolePerms = roleNode.data('details').permissions;
703
+ if (epPerms.some(epPerm =>
704
+ rolePerms.some(rp => implies(rp.permission, epPerm))
705
+ )) {
706
+ related.merge(roleNode);
707
+ }
708
+ });
709
+
710
+ // Add connecting edges between related nodes
711
+ related.merge(related.edgesWith(related));
712
+
713
+ // Include parent nodes (tag-groups) of collected nodes
714
+ related.nodes().forEach(n => {
715
+ const parent = n.parent();
716
+ if (parent.length > 0) {
717
+ related.merge(parent);
718
+ }
719
+ });
720
+
721
+ return related;
722
+ }
723
+
724
+ // Highlight path based on node type
725
+ function highlightPath(node) {
726
+ resetHighlight();
727
+
728
+ const type = node.data('type');
729
+ let related;
730
+
731
+ switch (type) {
732
+ case 'role':
733
+ related = getForwardPath(node);
734
+ break;
735
+ case 'context':
736
+ related = getContextPath(node);
737
+ break;
738
+ case 'permission':
739
+ case 'endpoint':
740
+ related = getBidirectionalPath(node);
741
+ break;
742
+ default:
743
+ related = node.closedNeighborhood();
744
+ }
745
+
746
+ cy.elements().addClass('faded');
747
+ related.removeClass('faded');
748
+ node.addClass('highlighted');
749
+ }
750
+
751
+ // Highlight paths for multiple selected nodes
752
+ function highlightMultiplePaths(nodes) {
753
+ resetHighlight();
754
+
755
+ let combined = cy.collection();
756
+ nodes.forEach(node => {
757
+ const type = node.data('type');
758
+ let related;
759
+ switch (type) {
760
+ case 'role':
761
+ related = getForwardPath(node);
762
+ break;
763
+ case 'context':
764
+ related = getContextPath(node);
765
+ break;
766
+ case 'permission':
767
+ case 'endpoint':
768
+ related = getBidirectionalPath(node);
769
+ break;
770
+ default:
771
+ related = node.closedNeighborhood();
772
+ }
773
+ combined = combined.union(related);
774
+ });
775
+
776
+ cy.elements().addClass('faded');
777
+ combined.removeClass('faded');
778
+ nodes.addClass('highlighted');
779
+ }
780
+
781
+ // Enter isolation mode - hide unrelated nodes, center related
782
+ function enterIsolationMode(node) {
783
+ const type = node.data('type');
784
+
785
+ // Context nodes don't support isolation mode
786
+ if (type === 'context' || type === 'tag-group') return;
787
+
788
+ isolationMode = true;
789
+ selectedNode = node;
790
+
791
+ // Use node-type-aware isolation functions
792
+ let related;
793
+ switch (type) {
794
+ case 'role':
795
+ related = isolateRole(node);
796
+ break;
797
+ case 'permission':
798
+ related = isolatePermission(node);
799
+ break;
800
+ case 'endpoint':
801
+ related = isolateEndpoint(node);
802
+ break;
803
+ default:
804
+ related = node.closedNeighborhood();
805
+ }
806
+
807
+ // Save original positions before layout
808
+ savedPositions = {};
809
+ cy.nodes().forEach(n => {
810
+ savedPositions[n.id()] = { ...n.position() };
811
+ });
812
+
813
+ // Hide unrelated elements (not just fade)
814
+ cy.elements().not(related).addClass('hidden');
815
+ related.removeClass('faded hidden');
816
+ node.addClass('highlighted');
817
+
818
+ // Apply custom left-to-right layout based on node types
819
+ const relatedNodes = related.filter('node').not('[type="tag-group"]');
820
+ const typeOrder = { role: 0, permission: 1, context: 2, endpoint: 3 };
821
+
822
+ // Group nodes by type
823
+ const nodesByType = { role: [], permission: [], endpoint: [], context: [] };
824
+ relatedNodes.forEach(n => {
825
+ const type = n.data('type');
826
+ if (nodesByType[type]) {
827
+ nodesByType[type].push(n);
828
+ }
829
+ });
830
+
831
+ // Calculate layout dimensions
832
+ const container = document.getElementById('cy');
833
+ const padding = 80;
834
+ const availableWidth = container.clientWidth - (padding * 2);
835
+ const availableHeight = container.clientHeight - (padding * 2);
836
+
837
+ // Count columns that have nodes
838
+ const activeColumns = Object.values(nodesByType).filter(arr => arr.length > 0).length;
839
+ const columnSpacing = activeColumns > 1 ? availableWidth / (activeColumns - 1) : 0;
840
+
841
+ // Position nodes
842
+ let colIndex = 0;
843
+ ['role', 'permission', 'context', 'endpoint'].forEach(type => {
844
+ const nodes = nodesByType[type];
845
+ if (nodes.length === 0) return;
846
+
847
+ const x = padding + (colIndex * columnSpacing);
848
+ const rowSpacing = Math.min(80, availableHeight / (nodes.length + 1));
849
+ const startY = (availableHeight - (nodes.length - 1) * rowSpacing) / 2 + padding;
850
+
851
+ nodes.forEach((n, i) => {
852
+ n.animate({
853
+ position: { x: x, y: startY + (i * rowSpacing) },
854
+ duration: 300,
855
+ easing: 'ease-out'
856
+ });
857
+ });
858
+
859
+ colIndex++;
860
+ });
861
+
862
+ // Fit view after animation
863
+ setTimeout(() => {
864
+ cy.fit(related, 50);
865
+ }, 320);
866
+
867
+ // Show reset button
868
+ document.getElementById('reset-view-btn').style.display = 'block';
869
+
870
+ // Show details
871
+ showDetails(node);
872
+ }
873
+
874
+ // Exit isolation mode - show all nodes, reset view
875
+ function exitIsolationMode() {
876
+ if (!isolationMode) return;
877
+
878
+ isolationMode = false;
879
+ selectedNode = null;
880
+
881
+ // Show all elements
882
+ cy.elements().removeClass('hidden faded highlighted');
883
+
884
+ // Clear Cytoscape selection
885
+ cy.$(':selected').unselect();
886
+
887
+ // Restore original positions
888
+ if (savedPositions) {
889
+ cy.nodes().forEach(n => {
890
+ const pos = savedPositions[n.id()];
891
+ if (pos) {
892
+ n.animate({
893
+ position: pos,
894
+ duration: 300,
895
+ easing: 'ease-out'
896
+ });
897
+ }
898
+ });
899
+ savedPositions = null;
900
+
901
+ // Fit view after animation
902
+ setTimeout(() => {
903
+ cy.fit(50);
904
+ }, 320);
905
+ } else {
906
+ // Fallback: just fit
907
+ cy.animate({
908
+ fit: { padding: 50 },
909
+ duration: 300,
910
+ easing: 'ease-out'
911
+ });
912
+ }
913
+
914
+ // Hide reset button
915
+ document.getElementById('reset-view-btn').style.display = 'none';
916
+
917
+ // Clear details
918
+ clearDetails();
919
+ }
920
+
921
+ // Show message when multiple nodes selected
922
+ function showMultiSelectMessage(count) {
923
+ document.getElementById('details-content').innerHTML = `
924
+ <p class="empty">${count} nodes selected</p>
925
+ <p class="empty" style="font-size: 0.8rem; margin-top: 8px;">
926
+ Click on canvas to deselect all
927
+ </p>
928
+ `;
929
+ }
930
+
931
+ // HTML escape utility to prevent XSS
932
+ function escapeHtml(text) {
933
+ const div = document.createElement('div');
934
+ div.textContent = text;
935
+ return div.innerHTML;
936
+ }
937
+
938
+ // Render a collapsible list with show more/less
939
+ function renderCollapsibleList(label, items, renderItem, defaultCount = 5) {
940
+ const safeLabel = escapeHtml(label);
941
+ const count = items.length;
942
+ const id = `collapse-${label.replace(/\s/g, '-').toLowerCase()}-${Math.random().toString(36).substr(2, 9)}`;
943
+
944
+ if (count === 0) {
945
+ return `
946
+ <div class="detail-item">
947
+ <div class="detail-label">${safeLabel} (0)</div>
948
+ <div class="detail-value"><em>None</em></div>
949
+ </div>
950
+ `;
951
+ }
952
+
953
+ const visibleItems = items.slice(0, defaultCount);
954
+ const hiddenItems = items.slice(defaultCount);
955
+
956
+ return `
957
+ <div class="detail-item">
958
+ <div class="detail-label">${safeLabel} (${count})</div>
959
+ <div class="detail-value">
960
+ <div id="${id}-visible">
961
+ ${visibleItems.map(renderItem).join('')}
962
+ </div>
963
+ ${hiddenItems.length > 0 ? `
964
+ <div id="${id}-hidden" style="display: none;">
965
+ ${hiddenItems.map(renderItem).join('')}
966
+ </div>
967
+ <a href="#" class="show-more-link" data-collapse-id="${id}" data-hidden-count="${hiddenItems.length}">
968
+ Show ${hiddenItems.length} more
969
+ </a>
970
+ ` : ''}
971
+ </div>
972
+ </div>
973
+ `;
974
+ }
975
+
976
+ // Toggle collapsible section (called via event delegation)
977
+ function toggleCollapse(link) {
978
+ const id = link.dataset.collapseId;
979
+ const hiddenCount = link.dataset.hiddenCount;
980
+ const hidden = document.getElementById(`${id}-hidden`);
981
+
982
+ if (!hidden) return;
983
+
984
+ if (hidden.style.display === 'none') {
985
+ hidden.style.display = 'block';
986
+ link.textContent = 'Show less';
987
+ } else {
988
+ hidden.style.display = 'none';
989
+ link.textContent = `Show ${hiddenCount} more`;
990
+ }
991
+ }
992
+
993
+ // Select a node by ID (for clickable resources)
994
+ function selectNodeById(nodeId) {
995
+ const node = cy.getElementById(nodeId);
996
+ if (node.length > 0) {
997
+ // Exit isolation mode if active
998
+ if (isolationMode) {
999
+ exitIsolationMode();
1000
+ }
1001
+
1002
+ // Clear previous selection
1003
+ cy.$(':selected').unselect();
1004
+
1005
+ // Select the node
1006
+ node.select();
1007
+ selectedNode = node;
1008
+ highlightPath(node);
1009
+ showDetails(node);
1010
+
1011
+ // Pan to node with animation
1012
+ cy.animate({
1013
+ center: { eles: node },
1014
+ duration: 300
1015
+ });
1016
+ }
1017
+ }
1018
+
1019
+ // Find endpoints that a permission grants access to
1020
+ function findEndpointsForPermission(permName) {
1021
+ return schema.endpoints.filter(e =>
1022
+ e.permissions.some(p => p === permName || implies(permName, p))
1023
+ );
1024
+ }
1025
+
1026
+ // Find roles that have access to an endpoint
1027
+ function findRolesForEndpoint(endpoint) {
1028
+ const result = [];
1029
+ schema.roles.forEach(role => {
1030
+ const hasAccess = endpoint.permissions.some(reqPerm =>
1031
+ role.permissions.some(p => implies(p.permission, reqPerm))
1032
+ );
1033
+ if (hasAccess) {
1034
+ result.push(role);
1035
+ }
1036
+ });
1037
+ return result;
1038
+ }
1039
+
1040
+ // Find endpoints accessible by a role
1041
+ function findEndpointsForRole(roleName) {
1042
+ const role = schema.roles.find(r => r.name === roleName);
1043
+ if (!role) return [];
1044
+
1045
+ return schema.endpoints.filter(endpoint =>
1046
+ endpoint.permissions.some(reqPerm =>
1047
+ role.permissions.some(p => implies(p.permission, reqPerm))
1048
+ )
1049
+ );
1050
+ }
1051
+
1052
+ // Find contexts required for endpoints a role can access
1053
+ function findContextsForRole(roleName) {
1054
+ const endpoints = findEndpointsForRole(roleName);
1055
+ const contextNames = new Set();
1056
+ endpoints.forEach(e => e.contexts.forEach(c => contextNames.add(c)));
1057
+ return schema.contexts.filter(c => contextNames.has(c.name));
1058
+ }
1059
+
1060
+ // Toggle legend visibility
1061
+ function toggleLegend() {
1062
+ const content = document.getElementById('legend-content');
1063
+ const toggle = document.getElementById('legend-toggle');
1064
+ content.classList.toggle('collapsed');
1065
+ toggle.textContent = content.classList.contains('collapsed') ? '+' : '−';
1066
+ }
1067
+
1068
+ // Build cytoscape elements from schema
1069
+ function buildElements(schema) {
1070
+ const elements = [];
1071
+ const nodes = new Set();
1072
+ const permissionNodes = new Map(); // Track permission nodes for wildcard matching
1073
+
1074
+ // Calculate responsive column positions
1075
+ const container = document.getElementById('cy');
1076
+ const containerWidth = container.clientWidth;
1077
+ const padding = 80;
1078
+ const availableWidth = containerWidth - (padding * 2);
1079
+ const columnSpacing = availableWidth / 3; // 4 columns, 3 gaps
1080
+
1081
+ const columnX = {
1082
+ role: padding,
1083
+ permission: padding + columnSpacing,
1084
+ context: padding + (columnSpacing * 2),
1085
+ endpoint: padding + (columnSpacing * 3)
1086
+ };
1087
+
1088
+ // Count nodes per type for Y distribution
1089
+ const counts = {
1090
+ role: schema.roles.length,
1091
+ permission: schema.permissions.length,
1092
+ endpoint: schema.endpoints.length,
1093
+ context: schema.contexts.length
1094
+ };
1095
+
1096
+ // Track index per type for Y calculation
1097
+ const indices = { role: 0, permission: 0, endpoint: 0, context: 0 };
1098
+
1099
+ // Calculate Y position for a node type
1100
+ function getY(type) {
1101
+ const count = counts[type];
1102
+ const index = indices[type]++;
1103
+ const spacing = Math.max(100, 500 / Math.max(count, 1));
1104
+ return 100 + (index * spacing);
1105
+ }
1106
+
1107
+ // Collect unique tags for compound nodes
1108
+ const tagSet = new Set();
1109
+ schema.endpoints.forEach(e => {
1110
+ if (e.tags && e.tags.length > 0) {
1111
+ e.tags.forEach(t => tagSet.add(t));
1112
+ }
1113
+ });
1114
+
1115
+ // Create parent nodes for each tag (compound nodes)
1116
+ tagSet.forEach(tag => {
1117
+ elements.push({
1118
+ data: {
1119
+ id: `tag:${tag}`,
1120
+ label: tag,
1121
+ type: 'tag-group'
1122
+ }
1123
+ });
1124
+ });
1125
+
1126
+ // Add role nodes
1127
+ schema.roles.forEach(role => {
1128
+ const nodeId = `role:${role.name}`;
1129
+ if (!nodes.has(nodeId)) {
1130
+ nodes.add(nodeId);
1131
+ elements.push({
1132
+ data: {
1133
+ id: nodeId,
1134
+ label: role.name,
1135
+ type: 'role',
1136
+ details: role
1137
+ },
1138
+ position: { x: columnX.role, y: getY('role') }
1139
+ });
1140
+ }
1141
+
1142
+ // Add edges from role to permissions
1143
+ role.permissions.forEach(perm => {
1144
+ const permNodeId = `permission:${perm.permission}`;
1145
+ elements.push({
1146
+ data: {
1147
+ id: `${nodeId}->${permNodeId}`,
1148
+ source: nodeId,
1149
+ target: permNodeId,
1150
+ scope: perm.scope,
1151
+ edgeType: 'role-permission'
1152
+ }
1153
+ });
1154
+ });
1155
+ });
1156
+
1157
+ // Add permission nodes
1158
+ schema.permissions.forEach(perm => {
1159
+ const nodeId = `permission:${perm.name}`;
1160
+ if (!nodes.has(nodeId)) {
1161
+ nodes.add(nodeId);
1162
+ permissionNodes.set(perm.name, nodeId);
1163
+ elements.push({
1164
+ data: {
1165
+ id: nodeId,
1166
+ label: perm.name,
1167
+ type: 'permission',
1168
+ details: perm
1169
+ },
1170
+ position: { x: columnX.permission, y: getY('permission') }
1171
+ });
1172
+ }
1173
+ });
1174
+
1175
+ // Add context nodes
1176
+ schema.contexts.forEach(ctx => {
1177
+ const nodeId = `context:${ctx.name}`;
1178
+ if (!nodes.has(nodeId)) {
1179
+ nodes.add(nodeId);
1180
+ elements.push({
1181
+ data: {
1182
+ id: nodeId,
1183
+ label: ctx.name,
1184
+ type: 'context',
1185
+ details: ctx
1186
+ },
1187
+ position: { x: columnX.context, y: getY('context') }
1188
+ });
1189
+ }
1190
+ });
1191
+
1192
+ // Add endpoint nodes and edges
1193
+ schema.endpoints.forEach(endpoint => {
1194
+ const nodeId = `endpoint:${endpoint.method}:${endpoint.path}`;
1195
+ if (!nodes.has(nodeId)) {
1196
+ nodes.add(nodeId);
1197
+ const nodeData = {
1198
+ id: nodeId,
1199
+ label: `${endpoint.method} ${endpoint.path}`,
1200
+ type: 'endpoint',
1201
+ details: endpoint
1202
+ };
1203
+
1204
+ // Add to parent compound node if endpoint has tags
1205
+ if (endpoint.tags && endpoint.tags.length > 0) {
1206
+ nodeData.parent = `tag:${endpoint.tags[0]}`;
1207
+ }
1208
+
1209
+ elements.push({
1210
+ data: nodeData,
1211
+ position: { x: columnX.endpoint, y: getY('endpoint') }
1212
+ });
1213
+ }
1214
+
1215
+ // Track edges to avoid duplicates
1216
+ const addedEdges = new Set();
1217
+
1218
+ // Helper to check if a permission has global scope from any role
1219
+ function hasGlobalScope(permName) {
1220
+ const permInfo = schema.permissions.find(p => p.name === permName);
1221
+ if (permInfo?.granted_by?.some(g => g.scope === 'global')) {
1222
+ return true;
1223
+ }
1224
+ // Check wildcard permissions that imply this permission
1225
+ for (const wp of schema.permissions) {
1226
+ if (isWildcardPermission(wp.name) && implies(wp.name, permName)) {
1227
+ if (wp.granted_by?.some(g => g.scope === 'global')) {
1228
+ return true;
1229
+ }
1230
+ }
1231
+ }
1232
+ return false;
1233
+ }
1234
+
1235
+ // Add edges based on permission scope
1236
+ endpoint.permissions.forEach(requiredPerm => {
1237
+ const permNodeId = `permission:${requiredPerm}`;
1238
+ const permNodeExists = nodes.has(permNodeId);
1239
+
1240
+ // Only create edges from exact permission if it exists
1241
+ if (permNodeExists) {
1242
+ // Check THIS permission's direct grants only (not wildcards)
1243
+ const permInfo = schema.permissions.find(p => p.name === requiredPerm);
1244
+ const hasDirectGlobalGrant = permInfo?.granted_by?.some(g => g.scope === 'global');
1245
+ const hasDirectContextualGrant = permInfo?.granted_by?.some(g => g.scope === 'contextual');
1246
+
1247
+ // Create global edge only if THIS permission has direct global grants
1248
+ if (hasDirectGlobalGrant) {
1249
+ const edgeId = `${permNodeId}->${nodeId}`;
1250
+ if (!addedEdges.has(edgeId)) {
1251
+ addedEdges.add(edgeId);
1252
+ elements.push({
1253
+ data: {
1254
+ id: edgeId,
1255
+ source: permNodeId,
1256
+ target: nodeId,
1257
+ scope: 'global',
1258
+ edgeType: 'direct'
1259
+ }
1260
+ });
1261
+ }
1262
+ }
1263
+
1264
+ // Create contextual edges if THIS permission has contextual grants
1265
+ if (hasDirectContextualGrant) {
1266
+ if (endpoint.contexts.length > 0) {
1267
+ // Has contexts: create Permission -> Context edges
1268
+ endpoint.contexts.forEach(ctx => {
1269
+ const ctxNodeId = `context:${ctx}`;
1270
+ const edgeId = `${permNodeId}->${ctxNodeId}`;
1271
+ if (!addedEdges.has(edgeId)) {
1272
+ addedEdges.add(edgeId);
1273
+ elements.push({
1274
+ data: {
1275
+ id: edgeId,
1276
+ source: permNodeId,
1277
+ target: ctxNodeId,
1278
+ scope: 'contextual',
1279
+ edgeType: 'permission-context'
1280
+ }
1281
+ });
1282
+ }
1283
+ });
1284
+ } else {
1285
+ // No contexts: direct edge to endpoint (styled as contextual)
1286
+ const edgeId = `${permNodeId}->${nodeId}`;
1287
+ if (!addedEdges.has(edgeId)) {
1288
+ addedEdges.add(edgeId);
1289
+ elements.push({
1290
+ data: {
1291
+ id: edgeId,
1292
+ source: permNodeId,
1293
+ target: nodeId,
1294
+ scope: 'contextual',
1295
+ edgeType: 'direct'
1296
+ }
1297
+ });
1298
+ }
1299
+ }
1300
+ }
1301
+ }
1302
+
1303
+ // Check for wildcard matches (always, regardless of exact permission existence)
1304
+ permissionNodes.forEach((wildcardNodeId, heldPerm) => {
1305
+ if (isWildcardPermission(heldPerm) && implies(heldPerm, requiredPerm)) {
1306
+ // Check the wildcard permission's actual grants directly
1307
+ const wildcardPermInfo = schema.permissions.find(p => p.name === heldPerm);
1308
+ const wildcardHasGlobal = wildcardPermInfo?.granted_by?.some(g => g.scope === 'global');
1309
+ const wildcardHasContextual = wildcardPermInfo?.granted_by?.some(g => g.scope === 'contextual');
1310
+
1311
+ // Create global edge if wildcard has global grants
1312
+ if (wildcardHasGlobal) {
1313
+ const wildcardEdgeId = `${wildcardNodeId}->wildcard->${nodeId}`;
1314
+ if (!addedEdges.has(wildcardEdgeId)) {
1315
+ addedEdges.add(wildcardEdgeId);
1316
+ elements.push({
1317
+ data: {
1318
+ id: wildcardEdgeId,
1319
+ source: wildcardNodeId,
1320
+ target: nodeId,
1321
+ scope: 'global',
1322
+ edgeType: 'wildcard'
1323
+ }
1324
+ });
1325
+ }
1326
+ }
1327
+
1328
+ // Create contextual edges if wildcard has contextual grants
1329
+ if (wildcardHasContextual) {
1330
+ if (endpoint.contexts.length > 0) {
1331
+ // Wildcard with contextual scope -> through context
1332
+ endpoint.contexts.forEach(ctx => {
1333
+ const ctxNodeId = `context:${ctx}`;
1334
+ const edgeId = `${wildcardNodeId}->wildcard->${ctxNodeId}`;
1335
+ if (!addedEdges.has(edgeId)) {
1336
+ addedEdges.add(edgeId);
1337
+ elements.push({
1338
+ data: {
1339
+ id: edgeId,
1340
+ source: wildcardNodeId,
1341
+ target: ctxNodeId,
1342
+ scope: 'contextual',
1343
+ edgeType: 'permission-context'
1344
+ }
1345
+ });
1346
+ }
1347
+ });
1348
+ } else {
1349
+ // No contexts, contextual wildcard -> direct to endpoint
1350
+ const wildcardEdgeId = `${wildcardNodeId}->wildcard-ctx->${nodeId}`;
1351
+ if (!addedEdges.has(wildcardEdgeId)) {
1352
+ addedEdges.add(wildcardEdgeId);
1353
+ elements.push({
1354
+ data: {
1355
+ id: wildcardEdgeId,
1356
+ source: wildcardNodeId,
1357
+ target: nodeId,
1358
+ scope: 'contextual',
1359
+ edgeType: 'wildcard'
1360
+ }
1361
+ });
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ });
1367
+ });
1368
+
1369
+ // Context -> Endpoint edges (always created for endpoints with contexts)
1370
+ endpoint.contexts.forEach(ctx => {
1371
+ const ctxNodeId = `context:${ctx}`;
1372
+ const edgeId = `${ctxNodeId}->${nodeId}`;
1373
+ if (!addedEdges.has(edgeId)) {
1374
+ addedEdges.add(edgeId);
1375
+ elements.push({
1376
+ data: {
1377
+ id: edgeId,
1378
+ source: ctxNodeId,
1379
+ target: nodeId,
1380
+ edgeType: 'context'
1381
+ }
1382
+ });
1383
+ }
1384
+ });
1385
+ });
1386
+
1387
+ return elements;
1388
+ }
1389
+
1390
+ // Initialize cytoscape
1391
+ function initCytoscape(elements) {
1392
+ cy = cytoscape({
1393
+ container: document.getElementById('cy'),
1394
+ elements: elements,
1395
+ boxSelectionEnabled: true,
1396
+ selectionType: 'additive',
1397
+ style: [
1398
+ {
1399
+ selector: 'node',
1400
+ style: {
1401
+ 'shape': 'round-rectangle',
1402
+ 'label': 'data(label)',
1403
+ 'text-valign': 'center',
1404
+ 'text-halign': 'center',
1405
+ 'background-color': ele => colors[ele.data('type')] || '#6e7681',
1406
+ 'color': '#fff',
1407
+ 'font-size': '10px',
1408
+ 'text-wrap': 'wrap',
1409
+ 'text-max-width': '120px',
1410
+ 'width': '80px',
1411
+ 'height': '80px',
1412
+ 'border-width': 2,
1413
+ 'border-color': ele => colors[ele.data('type')] || '#6e7681',
1414
+ 'shadow-blur': 15,
1415
+ 'shadow-color': ele => colors[ele.data('type')] || '#6e7681',
1416
+ 'shadow-opacity': 0.4,
1417
+ 'shadow-offset-x': 0,
1418
+ 'shadow-offset-y': 0
1419
+ }
1420
+ },
1421
+ {
1422
+ selector: 'node[type="endpoint"]',
1423
+ style: {
1424
+ 'shape': 'round-rectangle',
1425
+ 'width': '160px',
1426
+ 'height': '50px',
1427
+ 'text-max-width': '150px'
1428
+ }
1429
+ },
1430
+ {
1431
+ selector: 'node[type="role"]',
1432
+ style: {
1433
+ 'width': '100px',
1434
+ 'height': '50px',
1435
+ 'text-max-width': '90px'
1436
+ }
1437
+ },
1438
+ {
1439
+ selector: 'node[type="permission"]',
1440
+ style: {
1441
+ 'width': '120px',
1442
+ 'height': '50px',
1443
+ 'text-max-width': '110px'
1444
+ }
1445
+ },
1446
+ {
1447
+ selector: 'node[type="context"]',
1448
+ style: {
1449
+ 'width': '100px',
1450
+ 'height': '50px',
1451
+ 'text-max-width': '90px'
1452
+ }
1453
+ },
1454
+ {
1455
+ selector: 'node[type="tag-group"]',
1456
+ style: {
1457
+ 'shape': 'round-rectangle',
1458
+ 'background-opacity': 0.1,
1459
+ 'border-width': 2,
1460
+ 'border-color': '#6e7681',
1461
+ 'border-style': 'dashed',
1462
+ 'padding': '20px',
1463
+ 'text-valign': 'top',
1464
+ 'text-halign': 'center',
1465
+ 'font-weight': 'bold',
1466
+ 'font-size': '12px',
1467
+ 'color': '#8b949e',
1468
+ 'shadow-opacity': 0
1469
+ }
1470
+ },
1471
+ {
1472
+ selector: 'edge',
1473
+ style: {
1474
+ 'width': 2,
1475
+ 'line-color': '#484f58',
1476
+ 'target-arrow-color': '#484f58',
1477
+ 'target-arrow-shape': 'triangle',
1478
+ 'curve-style': 'bezier',
1479
+ 'arrow-scale': 1,
1480
+ 'opacity': 0.8
1481
+ }
1482
+ },
1483
+ {
1484
+ selector: 'edge[scope="global"]',
1485
+ style: {
1486
+ 'line-color': '#58a6ff',
1487
+ 'target-arrow-color': '#58a6ff',
1488
+ 'line-style': 'solid'
1489
+ }
1490
+ },
1491
+ {
1492
+ selector: 'edge[scope="contextual"]',
1493
+ style: {
1494
+ 'line-color': '#d29922',
1495
+ 'target-arrow-color': '#d29922',
1496
+ 'line-style': 'dashed'
1497
+ }
1498
+ },
1499
+ {
1500
+ selector: 'edge[edgeType="wildcard"]',
1501
+ style: {
1502
+ 'line-color': '#a371f7',
1503
+ 'target-arrow-color': '#a371f7',
1504
+ 'line-style': 'dotted',
1505
+ 'opacity': 0.7
1506
+ }
1507
+ },
1508
+ {
1509
+ selector: 'edge[edgeType="permission-context"]',
1510
+ style: {
1511
+ 'line-color': '#d29922',
1512
+ 'target-arrow-color': '#d29922',
1513
+ 'line-style': 'dashed'
1514
+ }
1515
+ },
1516
+ {
1517
+ selector: '.highlighted',
1518
+ style: {
1519
+ 'opacity': 1,
1520
+ 'border-width': 4,
1521
+ 'border-color': '#f778ba',
1522
+ 'shadow-color': '#f778ba',
1523
+ 'shadow-opacity': 0.8,
1524
+ 'z-index': 999
1525
+ }
1526
+ },
1527
+ {
1528
+ selector: '.faded',
1529
+ style: {
1530
+ 'opacity': 0.15
1531
+ }
1532
+ },
1533
+ {
1534
+ selector: '.hidden',
1535
+ style: {
1536
+ 'display': 'none'
1537
+ }
1538
+ }
1539
+ ],
1540
+ layout: {
1541
+ name: 'preset',
1542
+ fit: true,
1543
+ padding: 50
1544
+ }
1545
+ });
1546
+
1547
+ // Click handler for nodes - supports toggle deselect and multi-select
1548
+ cy.on('tap', 'node', function(evt) {
1549
+ const node = evt.target;
1550
+ if (node.data('type') === 'tag-group') return;
1551
+
1552
+ // Check for multi-select (Ctrl/Cmd held creates multiple :selected)
1553
+ const selectedNodes = cy.$(':selected');
1554
+
1555
+ if (selectedNodes.length > 1) {
1556
+ // Multi-select: highlight union of paths, show count
1557
+ highlightMultiplePaths(selectedNodes);
1558
+ showMultiSelectMessage(selectedNodes.length);
1559
+ selectedNode = null;
1560
+ } else if (selectedNode && selectedNode.id() === node.id()) {
1561
+ // Second click on same node - deselect
1562
+ cy.$(':selected').unselect(); // Clear Cytoscape selection
1563
+ selectedNode = null;
1564
+ resetHighlight();
1565
+ clearDetails();
1566
+ } else {
1567
+ // First click or different node - select
1568
+ cy.$(':selected').unselect(); // Clear previous selection
1569
+ node.select(); // Select in Cytoscape
1570
+ selectedNode = node;
1571
+ highlightPath(node);
1572
+ showDetails(node);
1573
+ }
1574
+ });
1575
+
1576
+ // Click on background to reset
1577
+ cy.on('tap', function(evt) {
1578
+ if (evt.target === cy) {
1579
+ cy.$(':selected').unselect(); // Clear Cytoscape selection
1580
+ selectedNode = null;
1581
+ resetHighlight();
1582
+ clearDetails();
1583
+ }
1584
+ });
1585
+
1586
+ // Double-click handler for isolation mode
1587
+ cy.on('dblclick', 'node', function(evt) {
1588
+ const node = evt.target;
1589
+ const type = node.data('type');
1590
+ if (type === 'tag-group' || type === 'context') return;
1591
+ if (isolationMode) return; // Prevent re-entry during isolation
1592
+
1593
+ enterIsolationMode(node);
1594
+ });
1595
+
1596
+ // Double-click on background to exit isolation
1597
+ cy.on('dblclick', function(evt) {
1598
+ if (evt.target === cy && isolationMode) {
1599
+ exitIsolationMode();
1600
+ }
1601
+ });
1602
+ }
1603
+
1604
+ // Highlight connected nodes and edges (now uses directional path)
1605
+ function highlightConnections(node) {
1606
+ highlightPath(node);
1607
+ }
1608
+
1609
+ // Reset highlighting
1610
+ function resetHighlight() {
1611
+ cy.elements().removeClass('faded highlighted');
1612
+ }
1613
+
1614
+ // Clear details panel
1615
+ function clearDetails() {
1616
+ document.getElementById('details-content').innerHTML = '<p class="empty">Click a node to see details</p>';
1617
+ }
1618
+
1619
+ // Show node details in sidebar
1620
+ function showDetails(node) {
1621
+ const type = node.data('type');
1622
+ const details = node.data('details');
1623
+ let html = '';
1624
+
1625
+ switch (type) {
1626
+ case 'role':
1627
+ const roleEndpoints = findEndpointsForRole(details.name);
1628
+ const roleContexts = findContextsForRole(details.name);
1629
+
1630
+ html = `
1631
+ <div class="detail-item">
1632
+ <div class="detail-label">Role Name</div>
1633
+ <div class="detail-value">${escapeHtml(details.name)}</div>
1634
+ </div>
1635
+ ${renderCollapsibleList(
1636
+ 'Permissions',
1637
+ details.permissions,
1638
+ p => `<span class="badge clickable permission" onclick="selectNodeById('permission:${escapeHtml(p.permission)}')">${escapeHtml(p.permission)} (${escapeHtml(p.scope)})</span>`
1639
+ )}
1640
+ ${renderCollapsibleList(
1641
+ 'Endpoints Accessible',
1642
+ roleEndpoints,
1643
+ e => `<span class="clickable-endpoint" onclick="selectNodeById('endpoint:${escapeHtml(e.method)}:${escapeHtml(e.path)}')">${escapeHtml(e.method)} ${escapeHtml(e.path)}</span>`
1644
+ )}
1645
+ ${renderCollapsibleList(
1646
+ 'Contexts Required',
1647
+ roleContexts,
1648
+ c => `<span class="badge clickable context" onclick="selectNodeById('context:${escapeHtml(c.name)}')">${escapeHtml(c.name)}</span>`
1649
+ )}
1650
+ `;
1651
+ break;
1652
+
1653
+ case 'permission':
1654
+ const permEndpoints = findEndpointsForPermission(details.name);
1655
+
1656
+ html = `
1657
+ <div class="detail-item">
1658
+ <div class="detail-label">Permission Name</div>
1659
+ <div class="detail-value">${escapeHtml(details.name)}</div>
1660
+ </div>
1661
+ ${renderCollapsibleList(
1662
+ 'Granted by Roles',
1663
+ details.granted_by,
1664
+ g => `<span class="badge clickable ${escapeHtml(g.scope)}" onclick="selectNodeById('role:${escapeHtml(g.role)}')">${escapeHtml(g.role)} (${escapeHtml(g.scope)})</span>`
1665
+ )}
1666
+ ${renderCollapsibleList(
1667
+ 'Endpoints Requiring This',
1668
+ permEndpoints,
1669
+ e => `<span class="clickable-endpoint" onclick="selectNodeById('endpoint:${escapeHtml(e.method)}:${escapeHtml(e.path)}')">${escapeHtml(e.method)} ${escapeHtml(e.path)}</span>`
1670
+ )}
1671
+ `;
1672
+ break;
1673
+
1674
+ case 'context':
1675
+ // Find endpoints guarded by this context
1676
+ const guardedEndpoints = schema.endpoints.filter(e => e.contexts.includes(details.name));
1677
+
1678
+ html = `
1679
+ <div class="detail-item">
1680
+ <div class="detail-label">Context Name</div>
1681
+ <div class="detail-value">${escapeHtml(details.name)}</div>
1682
+ </div>
1683
+ ${details.description ? `
1684
+ <div class="detail-item">
1685
+ <div class="detail-label">Description</div>
1686
+ <div class="context-description">${escapeHtml(details.description)}</div>
1687
+ </div>
1688
+ ` : ''}
1689
+ ${renderCollapsibleList(
1690
+ 'Endpoints Guarded',
1691
+ guardedEndpoints,
1692
+ e => `<span class="clickable-endpoint" onclick="selectNodeById('endpoint:${escapeHtml(e.method)}:${escapeHtml(e.path)}')">${escapeHtml(e.method)} ${escapeHtml(e.path)}</span>`
1693
+ )}
1694
+ `;
1695
+ break;
1696
+
1697
+ case 'endpoint':
1698
+ const endpointRoles = findRolesForEndpoint(details);
1699
+
1700
+ html = `
1701
+ <div class="detail-item">
1702
+ <div class="detail-label">Endpoint</div>
1703
+ <div class="detail-value">${escapeHtml(details.method)} ${escapeHtml(details.path)}</div>
1704
+ </div>
1705
+ ${details.summary ? `
1706
+ <div class="detail-item">
1707
+ <div class="detail-label">Summary</div>
1708
+ <div class="detail-value">${escapeHtml(details.summary)}</div>
1709
+ </div>
1710
+ ` : ''}
1711
+ ${details.tags && details.tags.length > 0 ? `
1712
+ <div class="detail-item">
1713
+ <div class="detail-label">Tags</div>
1714
+ <div class="detail-value">${details.tags.map(t => escapeHtml(t)).join(', ')}</div>
1715
+ </div>
1716
+ ` : ''}
1717
+ ${renderCollapsibleList(
1718
+ 'Required Permissions',
1719
+ details.permissions,
1720
+ p => `<span class="badge clickable permission" onclick="selectNodeById('permission:${escapeHtml(p)}')">${escapeHtml(p)}</span>`
1721
+ )}
1722
+ ${renderCollapsibleList(
1723
+ 'Required Contexts',
1724
+ details.contexts,
1725
+ c => `<span class="badge clickable context" onclick="selectNodeById('context:${escapeHtml(c)}')">${escapeHtml(c)}</span>`
1726
+ )}
1727
+ ${renderCollapsibleList(
1728
+ 'Roles with Access',
1729
+ endpointRoles,
1730
+ r => `<span class="badge clickable global" onclick="selectNodeById('role:${escapeHtml(r.name)}')">${escapeHtml(r.name)}</span>`
1731
+ )}
1732
+ `;
1733
+ break;
1734
+ }
1735
+
1736
+ document.getElementById('details-content').innerHTML = html;
1737
+ }
1738
+
1739
+ // Search nodes
1740
+ function searchNodes(query) {
1741
+ resetHighlight();
1742
+ if (!query) {
1743
+ cy.elements().removeClass('faded highlighted');
1744
+ return;
1745
+ }
1746
+
1747
+ // Normalize query - trim and lowercase
1748
+ query = query.trim().toLowerCase();
1749
+ if (!query) {
1750
+ cy.elements().removeClass('faded highlighted');
1751
+ return;
1752
+ }
1753
+
1754
+ // Find matching nodes
1755
+ const matchingNodes = cy.nodes().filter(node => {
1756
+ const label = (node.data('label') || '').toLowerCase();
1757
+ return label.includes(query);
1758
+ });
1759
+
1760
+ if (matchingNodes.length === 0) {
1761
+ // No matches - fade everything
1762
+ cy.elements().addClass('faded');
1763
+ return;
1764
+ }
1765
+
1766
+ // Collect all related elements for matching nodes
1767
+ let related = cy.collection();
1768
+ matchingNodes.forEach(node => {
1769
+ const type = node.data('type');
1770
+ let nodeRelated;
1771
+ switch (type) {
1772
+ case 'role':
1773
+ nodeRelated = getForwardPath(node);
1774
+ break;
1775
+ case 'context':
1776
+ nodeRelated = getContextPath(node);
1777
+ break;
1778
+ case 'permission':
1779
+ case 'endpoint':
1780
+ nodeRelated = getBidirectionalPath(node);
1781
+ break;
1782
+ default:
1783
+ nodeRelated = node.closedNeighborhood();
1784
+ }
1785
+ related = related.union(nodeRelated);
1786
+ });
1787
+
1788
+ // Fade all, then unfade related
1789
+ cy.elements().addClass('faded');
1790
+ related.removeClass('faded');
1791
+ matchingNodes.addClass('highlighted');
1792
+ }
1793
+
1794
+ // Update stats
1795
+ function updateStats(schema) {
1796
+ document.getElementById('stat-roles').textContent = schema.roles.length;
1797
+ document.getElementById('stat-permissions').textContent = schema.permissions.length;
1798
+ document.getElementById('stat-contexts').textContent = schema.contexts.length;
1799
+ document.getElementById('stat-endpoints').textContent = schema.endpoints.length;
1800
+ }
1801
+
1802
+ // Handle window resize
1803
+ function handleResize() {
1804
+ clearTimeout(resizeTimeout);
1805
+ resizeTimeout = setTimeout(() => {
1806
+ if (cy && schema) {
1807
+ const elements = buildElements(schema);
1808
+ cy.json({ elements });
1809
+ cy.fit(50);
1810
+ }
1811
+ }, 250);
1812
+ }
1813
+
1814
+ // Load schema and initialize
1815
+ async function init() {
1816
+ try {
1817
+ const response = await fetch(SCHEMA_URL);
1818
+ if (!response.ok) {
1819
+ throw new Error(`HTTP error! status: ${response.status}`);
1820
+ }
1821
+ schema = await response.json();
1822
+
1823
+ // Clear loading message but keep legend overlay
1824
+ const loadingEl = document.querySelector('#cy .loading');
1825
+ if (loadingEl) {
1826
+ loadingEl.remove();
1827
+ }
1828
+
1829
+ const elements = buildElements(schema);
1830
+ initCytoscape(elements);
1831
+ updateStats(schema);
1832
+
1833
+ // Setup search
1834
+ const searchInput = document.getElementById('search');
1835
+ searchInput.addEventListener('input', (e) => {
1836
+ searchNodes(e.target.value);
1837
+ });
1838
+
1839
+ // Prevent browser shortcuts (like "/" for quick find) from intercepting input
1840
+ searchInput.addEventListener('keydown', (e) => {
1841
+ e.stopPropagation();
1842
+ });
1843
+
1844
+ // Setup resize handler
1845
+ window.addEventListener('resize', handleResize);
1846
+
1847
+ // Escape key to exit isolation mode
1848
+ document.addEventListener('keydown', function(evt) {
1849
+ if (evt.key === 'Escape' && isolationMode) {
1850
+ exitIsolationMode();
1851
+ }
1852
+ });
1853
+
1854
+ // Reset View button handler
1855
+ document.getElementById('reset-view-btn').addEventListener('click', exitIsolationMode);
1856
+
1857
+ // Event delegation for show-more/less links in details panel
1858
+ document.getElementById('details-content').addEventListener('click', function(evt) {
1859
+ if (evt.target.classList.contains('show-more-link')) {
1860
+ evt.preventDefault();
1861
+ toggleCollapse(evt.target);
1862
+ }
1863
+ });
1864
+
1865
+ } catch (error) {
1866
+ console.error('Failed to load schema:', error);
1867
+ document.getElementById('cy').innerHTML =
1868
+ `<div class="error">
1869
+ <h2>Failed to load RBAC schema</h2>
1870
+ <p>${error.message}</p>
1871
+ </div>`;
1872
+ }
1873
+ }
1874
+
1875
+ // Start
1876
+ init();
1877
+ </script>
1878
+ </body>
1879
+ </html>