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.
- fastapi_rbac/__init__.py +35 -0
- fastapi_rbac/context.py +39 -0
- fastapi_rbac/core.py +111 -0
- fastapi_rbac/dependencies.py +254 -0
- fastapi_rbac/exceptions.py +8 -0
- fastapi_rbac/permissions.py +87 -0
- fastapi_rbac/py.typed +1 -0
- fastapi_rbac/router.py +319 -0
- fastapi_rbac/ui/__init__.py +9 -0
- fastapi_rbac/ui/routes.py +67 -0
- fastapi_rbac/ui/schema.py +253 -0
- fastapi_rbac/ui/static/index.html +1879 -0
- fastapi_rbac_authz-0.2.0.dist-info/METADATA +269 -0
- fastapi_rbac_authz-0.2.0.dist-info/RECORD +15 -0
- fastapi_rbac_authz-0.2.0.dist-info/WHEEL +4 -0
|
@@ -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>
|