super_auth 0.3.3 → 0.7.0

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +286 -22
  6. data/USAGE.md +41 -16
  7. data/config/routes.rb +9 -71
  8. data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
  9. data/db/migrate/1_users.rb +1 -1
  10. data/db/migrate/5_resources.rb +1 -1
  11. data/db/migrate/7_authorization.rb +2 -2
  12. data/db/migrate/8_add_indexes_to_edges.rb +7 -0
  13. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
  14. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
  16. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  17. data/exe/super_auth-editor +9 -0
  18. data/lib/generators/super_auth/install/templates/README +15 -10
  19. data/lib/generators/super_auth/install/templates/super_auth.rb +16 -5
  20. data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
  21. data/lib/generators/super_auth/rls/templates/migration.rb.erb +15 -0
  22. data/lib/super_auth/active_record/by_current_user.rb +25 -5
  23. data/lib/super_auth/active_record/resource.rb +41 -0
  24. data/lib/super_auth/active_record/user.rb +3 -1
  25. data/lib/super_auth/authorization.rb +12 -0
  26. data/lib/super_auth/edge.rb +65 -100
  27. data/lib/super_auth/editor/cli.rb +91 -0
  28. data/lib/super_auth/editor/index.html +423 -0
  29. data/lib/super_auth/editor/seed.rb +170 -0
  30. data/lib/super_auth/editor.rb +273 -0
  31. data/lib/super_auth/nestable.rb +41 -3
  32. data/lib/super_auth/railtie.rb +0 -2
  33. data/lib/super_auth/rls.rb +256 -0
  34. data/lib/super_auth/user.rb +3 -1
  35. data/lib/super_auth/version.rb +1 -1
  36. data/lib/super_auth.rb +85 -0
  37. data/lib/tasks/super_auth_tasks.rake +28 -0
  38. metadata +15 -9
  39. data/VISUALIZATION.md +0 -58
  40. data/app/controllers/super_auth/graph_controller.rb +0 -654
  41. data/app/views/super_auth/graph/index.html.erb +0 -1408
  42. data/super_auth.gemspec +0 -35
  43. data/visualization.html +0 -747
@@ -0,0 +1,423 @@
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">
6
+ <title>SuperAuth · Graph Editor</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0f1216; --panel:#161a20; --panel-2:#1b2028; --line:#272e38;
10
+ --ink:#e9e7e1; --ink-soft:#9aa1ac; --ink-faint:#646b76;
11
+ --brass:#d4a84b;
12
+ --c-group:#49b3a3; --c-user:#d4a84b; --c-role:#a888dc; --c-perm:#5b9bd4; --c-resource:#d67d6b;
13
+ --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
14
+ --sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
15
+ }
16
+ *{box-sizing:border-box}
17
+ html,body{height:100%;margin:0}
18
+ body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;display:flex;flex-direction:column;overflow:hidden}
19
+ button{font-family:inherit;cursor:pointer}
20
+ ::selection{background:var(--brass);color:#161206}
21
+
22
+ /* ---- header ---- */
23
+ header{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel);flex:none}
24
+ .brand{font-family:var(--mono);font-weight:700;letter-spacing:-.02em;display:flex;align-items:center;gap:9px;font-size:15px}
25
+ .brand .dot{width:12px;height:12px;border-radius:50%;background:var(--brass);box-shadow:0 0 0 3px rgba(212,168,75,.18)}
26
+ .tools{display:flex;gap:8px;margin-left:6px}
27
+ .tbtn{background:transparent;border:1px solid var(--line);color:var(--ink-soft);border-radius:7px;padding:7px 12px;font-size:12.5px;font-family:var(--mono);transition:all .15s}
28
+ .tbtn:hover{border-color:var(--brass);color:var(--ink)}
29
+ .tbtn.on{background:var(--brass);border-color:var(--brass);color:#161206;font-weight:600}
30
+ .spacer{flex:1}
31
+ .hint{color:var(--ink-faint);font-size:12px;font-family:var(--mono)}
32
+
33
+ /* ---- compiled strip ---- */
34
+ #compiled{display:flex;align-items:center;gap:12px;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel-2);flex:none;font-family:var(--mono);font-size:12px;color:var(--ink-soft)}
35
+ #compiled b{color:var(--ink)}
36
+ #compiled .tbtn{padding:4px 10px}
37
+
38
+ /* ---- inspector strip ---- */
39
+ #inspector{display:none;align-items:center;gap:12px;padding:8px 16px;border-bottom:1px solid var(--line);background:var(--panel-2);flex:none;min-height:44px;overflow-x:auto;white-space:nowrap}
40
+ #inspector.show{display:flex}
41
+ .sel-chip{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px;padding:5px 10px;border-radius:7px;border:1px solid var(--line);background:var(--panel)}
42
+ .sel-chip .swatch{width:9px;height:9px;border-radius:2px}
43
+ .rel-counts{display:flex;gap:10px;font-family:var(--mono);font-size:11.5px;color:var(--ink-soft)}
44
+ .rel-counts b{color:var(--ink)}
45
+ .conns{display:flex;gap:6px;align-items:center;flex-wrap:nowrap}
46
+ .conns .lbl{font-size:11px;color:var(--ink-faint);font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em}
47
+ .edge-chip{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11.5px;padding:3px 6px 3px 9px;border-radius:6px;border:1px solid var(--line);background:var(--panel);color:var(--ink-soft)}
48
+ .edge-chip .x{color:var(--ink-faint);border:none;background:none;padding:0 2px;font-size:13px;line-height:1}
49
+ .edge-chip .x:hover{color:var(--c-resource)}
50
+
51
+ /* ---- layout ---- */
52
+ main{flex:1;display:flex;flex-direction:column;min-height:0;gap:10px;padding:10px}
53
+ .region{display:grid;gap:10px;min-height:0}
54
+ .region.top{flex:1.15;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;
55
+ grid-template-areas:"group role" "user perm"}
56
+ .region.bottom{flex:.85;grid-template-columns:1fr 1fr;grid-template-rows:1fr;
57
+ grid-template-areas:"resource user2"}
58
+
59
+ /* ---- box ---- */
60
+ .box{background:var(--panel);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
61
+ .box[data-area=group]{grid-area:group;--accent:var(--c-group)}
62
+ .box[data-area=role]{grid-area:role;--accent:var(--c-role)}
63
+ .box[data-area=user]{grid-area:user;--accent:var(--c-user)}
64
+ .box[data-area=permission]{grid-area:perm;--accent:var(--c-perm)}
65
+ .box[data-area=resource]{grid-area:resource;--accent:var(--c-resource)}
66
+ .box[data-area=user2]{grid-area:user2;--accent:var(--c-user)}
67
+ .box-head{display:flex;align-items:center;gap:9px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
68
+ .box-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none;box-shadow:0 0 10px -2px var(--accent)}
69
+ .box-head .title{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.02em}
70
+ .box-head .count{font-family:var(--mono);font-size:11px;color:var(--ink-faint);background:var(--panel-2);border:1px solid var(--line);border-radius:20px;padding:1px 8px}
71
+ .box-head .add{margin-left:auto;background:transparent;border:1px solid var(--line);color:var(--ink-soft);width:24px;height:24px;border-radius:6px;font-size:15px;line-height:1;display:grid;place-items:center}
72
+ .box-head .add:hover{border-color:var(--accent);color:var(--accent)}
73
+ .filter{margin:8px 10px 6px;flex:none}
74
+ .filter input{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:7px;color:var(--ink);padding:7px 10px;font-size:12.5px;font-family:var(--mono)}
75
+ .filter input::placeholder{color:var(--ink-faint)}
76
+ .filter input:focus{outline:none;border-color:var(--accent)}
77
+ .list{flex:1;overflow-y:auto;padding:2px 8px 10px}
78
+ .list::-webkit-scrollbar{width:9px}
79
+ .list::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
80
+
81
+ .item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;cursor:pointer;font-size:13px;color:var(--ink-soft);border:1px solid transparent;transition:background .12s,border-color .12s}
82
+ .item:hover{background:var(--panel-2);color:var(--ink)}
83
+ .item .name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
84
+ .item .ext{flex:none;color:var(--ink-faint);font-family:var(--mono);font-size:11px}
85
+ .item .tick{width:7px;height:7px;border-radius:50%;background:var(--accent);opacity:0;flex:none}
86
+ .item.selected{background:color-mix(in srgb,var(--accent) 22%,transparent);border-color:var(--accent);color:var(--ink);font-weight:600}
87
+ .item.selected .tick{opacity:1}
88
+ .item.connect-first{border-color:var(--brass);box-shadow:0 0 0 1px var(--brass) inset}
89
+ .item .del{opacity:0;background:none;border:none;color:var(--ink-faint);font-size:14px;line-height:1;padding:0 3px}
90
+ .item:hover .del{opacity:1}
91
+ .item .del:hover{color:var(--c-resource)}
92
+ .item .depth{flex:none;color:var(--ink-faint);font-family:var(--mono);font-size:11px}
93
+ .empty{color:var(--ink-faint);font-size:12px;font-family:var(--mono);padding:14px 8px;text-align:center}
94
+ .connectable .item{cursor:crosshair}
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <header>
99
+ <div class="brand"><span class="dot"></span>super_auth · graph editor</div>
100
+ <div class="tools">
101
+ <button class="tbtn" id="btn-connect">+ Connect mode</button>
102
+ <button class="tbtn" id="btn-clear">Clear selection</button>
103
+ <button class="tbtn" id="btn-reload">↻ Reload</button>
104
+ </div>
105
+ <div class="spacer"></div>
106
+ <div class="hint" id="hint">Click any record to trace its relationships across every box.</div>
107
+ </header>
108
+
109
+ <div id="compiled">
110
+ <span>Runtime access comes from the compiled authorizations table: <b id="compiled-count">…</b> rows. Edits here take effect after a recompile.</span>
111
+ <button class="tbtn" id="btn-compile">Recompile</button>
112
+ </div>
113
+
114
+ <div id="inspector"></div>
115
+
116
+ <main>
117
+ <section class="region top">
118
+ <div class="box" data-area="group" data-type="group"></div>
119
+ <div class="box" data-area="role" data-type="role"></div>
120
+ <div class="box" data-area="user" data-type="user"></div>
121
+ <div class="box" data-area="permission" data-type="permission"></div>
122
+ </section>
123
+ <section class="region bottom">
124
+ <div class="box" data-area="resource" data-type="resource"></div>
125
+ <div class="box" data-area="user2" data-type="user"></div>
126
+ </section>
127
+ </main>
128
+
129
+ <script>
130
+ // The app may be mounted under a prefix; every API call is relative to this page.
131
+ const API = location.pathname.replace(/\/$/, "");
132
+ const TITLES = {group:"Groups", role:"Roles", user:"Users", permission:"Permissions", resource:"Resources"};
133
+ const NESTED = new Set(["group","role"]);
134
+
135
+ let GRAPH = null; // {groups,roles,users,permissions,resources,edges,authorizations_count}
136
+ let selection = null; // {type,id}
137
+ let relevant = null; // Set of "type:id" or null
138
+ let connectMode = false;
139
+ let connectFirst = null; // {type,id}
140
+ const boxFilters = {}; // area -> text
141
+
142
+ const boxes = [...document.querySelectorAll(".box")];
143
+ const byType = t => ({group:"groups",role:"roles",user:"users",permission:"permissions",resource:"resources"}[t]);
144
+ const key = (t,id) => t+":"+id;
145
+
146
+ // ---- data ----
147
+ async function load(){
148
+ const r = await fetch(API + "/api/graph");
149
+ if(!r.ok){ setHint(`<span style="color:var(--c-resource)">could not load the graph (HTTP ${r.status})</span>`); return; }
150
+ GRAPH = await r.json();
151
+ document.getElementById("compiled-count").textContent = GRAPH.authorizations_count;
152
+ recompute();
153
+ render();
154
+ }
155
+
156
+ // ---- directional traversal ----
157
+ // Authorization flows user → group → role → permission → resource. From the
158
+ // selected node we collect everything DOWNSTREAM (what it can reach) and
159
+ // everything UPSTREAM (what can reach it). So a user shows what they can access;
160
+ // a resource shows who can access it. Group/role hierarchy is bidirectional
161
+ // (a parent's grants apply to children, and a parent "contains" its children).
162
+ const RANK = {user:0, group:1, role:2, permission:3, resource:4};
163
+
164
+ function buildDirected(){
165
+ const fwd = new Map(); // toward resources
166
+ const bwd = new Map(); // toward users
167
+ const add = (m,a,b) => { if(!m.has(a)) m.set(a,new Set()); m.get(a).add(b); };
168
+ const dir = (a,b) => { add(fwd,a,b); add(bwd,b,a); }; // a is upstream of b
169
+ for(const e of GRAPH.edges){
170
+ const ends = [];
171
+ if(e.user_id) ends.push(["user",e.user_id]);
172
+ if(e.group_id) ends.push(["group",e.group_id]);
173
+ if(e.role_id) ends.push(["role",e.role_id]);
174
+ if(e.permission_id) ends.push(["permission",e.permission_id]);
175
+ if(e.resource_id) ends.push(["resource",e.resource_id]);
176
+ for(let i=0;i<ends.length;i++) for(let j=i+1;j<ends.length;j++){
177
+ const [ta,ia]=ends[i], [tb,ib]=ends[j];
178
+ const a=key(ta,ia), b=key(tb,ib);
179
+ if(RANK[ta] <= RANK[tb]) dir(a,b); else dir(b,a);
180
+ }
181
+ }
182
+ // Hierarchy — directional, so siblings never leak:
183
+ // • Group grants flow DOWN to members of descendant groups, so a member
184
+ // inherits ANCESTOR grants → a child group is upstream of its parent.
185
+ // • Role grants flow to DESCENDANT roles, so holding a parent role includes
186
+ // its children → a parent role is upstream of its child.
187
+ // (Selecting a child group therefore reaches its ancestors, never its siblings.)
188
+ for(const g of GRAPH.groups) if(g.parent_id) dir(key("group",g.id), key("group",g.parent_id));
189
+ for(const r of GRAPH.roles) if(r.parent_id) dir(key("role",r.parent_id), key("role",r.id));
190
+ return {fwd,bwd};
191
+ }
192
+
193
+ function reach(map,start){
194
+ const seen=new Set([start]), q=[start];
195
+ while(q.length){ const c=q.shift(); for(const nb of (map.get(c)||[])) if(!seen.has(nb)){ seen.add(nb); q.push(nb); } }
196
+ return seen;
197
+ }
198
+
199
+ function recompute(){
200
+ if(!selection){ relevant = null; return; }
201
+ const {fwd,bwd} = buildDirected();
202
+ const start = key(selection.type, selection.id);
203
+ const down = reach(fwd,start); // what it can reach
204
+ const up = reach(bwd,start); // what can reach it
205
+ const rel = new Set([...down, ...up]);
206
+ // Also surface "peer" users — people whose own access overlaps this subgraph —
207
+ // so selecting a user reveals teammates, not just themselves. (Users are pure
208
+ // source nodes, so they'd never appear otherwise.)
209
+ for(const u of GRAPH.users){
210
+ const uk = key("user", u.id);
211
+ if(rel.has(uk)) continue;
212
+ for(const n of reach(fwd, uk)){ if(rel.has(n)){ rel.add(uk); break; } }
213
+ }
214
+ relevant = rel;
215
+ }
216
+
217
+ // depth for nested types (for indentation)
218
+ function depthOf(type, node){
219
+ const list = GRAPH[byType(type)];
220
+ const map = new Map(list.map(n=>[n.id,n]));
221
+ let d=0, cur=node;
222
+ while(cur && cur.parent_id && map.has(cur.parent_id)){ d++; cur=map.get(cur.parent_id); if(d>20)break; }
223
+ return d;
224
+ }
225
+
226
+ // ---- rendering ----
227
+ function render(){
228
+ for(const box of boxes){
229
+ const type = box.dataset.type;
230
+ const area = box.dataset.area;
231
+ let list = GRAPH[byType(type)] || [];
232
+
233
+ // graph-traversal relevance filter (from selection)
234
+ if(relevant) list = list.filter(n => relevant.has(key(type,n.id)));
235
+ // per-box text filter
236
+ const f = (boxFilters[area]||"").toLowerCase();
237
+ let shown = f ? list.filter(n => haystack(n).includes(f)) : list;
238
+
239
+ const total = GRAPH[byType(type)].length;
240
+ const countTxt = (relevant || f) ? `${shown.length}/${total}` : `${total}`;
241
+
242
+ let items = shown.map(n => {
243
+ const isSel = selection && selection.type===type && selection.id===n.id;
244
+ const isCF = connectFirst && connectFirst.type===type && connectFirst.id===n.id;
245
+ const depth = NESTED.has(type) ? depthOf(type,n) : 0;
246
+ const pad = depth ? `style="padding-left:${8+depth*14}px"` : "";
247
+ const arrow = depth ? `<span class="depth">${"└".padStart(1)} </span>` : "";
248
+ const name = n.name ?? "";
249
+ // The external record occupies one slot: its label when the graph
250
+ // stored one, otherwise the Type#id that used to be the only
251
+ // rendering. Either way the other half is the tooltip.
252
+ const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
253
+ const ext = !ref ? "" : (n.super_auth_label
254
+ ? `<span class="ext" title="${ref}">${escapeHtml(n.super_auth_label)}</span>`
255
+ : `<span class="ext" title="external record">${ref}</span>`);
256
+ return `<div class="item ${isSel?'selected':''} ${isCF?'connect-first':''}" data-id="${n.id}" ${pad}>
257
+ <span class="tick"></span>
258
+ ${arrow}<span class="name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>${ext}
259
+ <button class="del" data-del="${n.id}" title="Delete">✕</button>
260
+ </div>`;
261
+ }).join("");
262
+ if(!shown.length) items = `<div class="empty">${relevant ? "no related "+TITLES[type].toLowerCase() : "empty"}</div>`;
263
+
264
+ box.innerHTML = `
265
+ <div class="box-head">
266
+ <span class="swatch"></span>
267
+ <span class="title">${TITLES[type]}</span>
268
+ <span class="count">${countTxt}</span>
269
+ <button class="add" title="Add ${TITLES[type]}">+</button>
270
+ </div>
271
+ <div class="filter"><input type="text" placeholder="filter ${TITLES[type].toLowerCase()}…" value="${escapeHtml(boxFilters[area]||'')}"></div>
272
+ <div class="list ${connectMode?'connectable':''}">${items}</div>`;
273
+
274
+ // wire events
275
+ const input = box.querySelector(".filter input");
276
+ input.addEventListener("input", e => { boxFilters[area]=e.target.value; render(); reFocus(box); });
277
+ box.querySelector(".add").addEventListener("click", () => addNode(type));
278
+ box.querySelectorAll(".item").forEach(el=>{
279
+ el.addEventListener("click", ev=>{
280
+ if(ev.target.closest(".del")) return;
281
+ onItemClick(type, +el.dataset.id);
282
+ });
283
+ });
284
+ box.querySelectorAll(".del").forEach(el=>{
285
+ el.addEventListener("click", ()=> deleteNode(type, +el.dataset.del));
286
+ });
287
+ }
288
+ renderInspector();
289
+ }
290
+
291
+ let focusArea=null;
292
+ function reFocus(box){ // keep caret in the filter after re-render
293
+ const area=box.dataset.area;
294
+ const input=document.querySelector(`.box[data-area="${area}"] .filter input`);
295
+ if(input){ input.focus(); const v=input.value; input.setSelectionRange(v.length,v.length); }
296
+ }
297
+
298
+ function renderInspector(){
299
+ const ins = document.getElementById("inspector");
300
+ if(!selection){ ins.classList.remove("show"); ins.innerHTML=""; return; }
301
+ ins.classList.add("show");
302
+ const node = GRAPH[byType(selection.type)].find(n=>n.id===selection.id);
303
+ if(!node){ ins.classList.remove("show"); return; }
304
+ const color = {group:"var(--c-group)",role:"var(--c-role)",user:"var(--c-user)",permission:"var(--c-perm)",resource:"var(--c-resource)"}[selection.type];
305
+
306
+ // relevance counts per type (excluding the selected type's self)
307
+ const counts = ["group","role","user","permission","resource"].map(t=>{
308
+ const n = GRAPH[byType(t)].filter(x=>relevant.has(key(t,x.id)) && !(t===selection.type && x.id===selection.id)).length;
309
+ return `<span><b>${n}</b> ${TITLES[t].toLowerCase()}</span>`;
310
+ }).join("");
311
+
312
+ // direct edges of the selected node (editable)
313
+ const col = {user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[selection.type];
314
+ const direct = GRAPH.edges.filter(e=>e[col]===selection.id);
315
+ const edgeChips = direct.map(e=>{
316
+ const other = ["user","group","role","permission","resource"].filter(t=>{
317
+ const c={user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[t];
318
+ return e[c] && !(t===selection.type && e[c]===selection.id);
319
+ }).map(t=>{
320
+ const c={user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[t];
321
+ return `${escapeHtml(nameFor(t,e[c]))} <span style="color:var(--ink-faint)">(${t})</span>`;
322
+ }).join(", ");
323
+ return `<span class="edge-chip">→ ${other||"—"} <button class="x" data-edge="${e.id}" title="Delete edge">✕</button></span>`;
324
+ }).join("");
325
+
326
+ ins.innerHTML = `
327
+ <span class="sel-chip"><span class="swatch" style="background:${color}"></span>${escapeHtml(node.name ?? "")}
328
+ <span style="color:var(--ink-faint)">${selection.type}</span></span>
329
+ <span class="rel-counts">${counts}</span>
330
+ <span class="conns"><span class="lbl">edges:</span>${edgeChips||'<span style="color:var(--ink-faint);font-family:var(--mono);font-size:11px">none</span>'}</span>`;
331
+ ins.querySelectorAll("[data-edge]").forEach(b=> b.addEventListener("click", ()=> deleteEdge(+b.dataset.edge)));
332
+ }
333
+
334
+ // ---- interactions ----
335
+ function onItemClick(type,id){
336
+ if(connectMode){
337
+ if(!connectFirst){ connectFirst={type,id}; setHint(`Connecting from <b>${escapeHtml(nameFor(type,id))}</b> — pick a second record.`); render(); return; }
338
+ if(connectFirst.type===type && connectFirst.id===id){ connectFirst=null; setHint("Connect mode: pick the first record."); render(); return; }
339
+ createEdge(connectFirst, {type,id});
340
+ return;
341
+ }
342
+ if(selection && selection.type===type && selection.id===id){ selection=null; } // toggle off
343
+ else { selection={type,id}; }
344
+ recompute(); render();
345
+ }
346
+
347
+ async function createEdge(a,b){
348
+ const res = await fetch(API + "/api/edges",{method:"POST",headers:{"Content-Type":"application/json"},
349
+ body:JSON.stringify({a_type:a.type,a_id:a.id,b_type:b.type,b_id:b.id})});
350
+ const j = await res.json();
351
+ connectFirst=null;
352
+ if(!res.ok){ setHint(`<span style="color:var(--c-resource)">${escapeHtml(j.error||"could not connect")}</span>`); }
353
+ else setHint(`Connected <b>${escapeHtml(nameFor(a.type,a.id))}</b> → <b>${escapeHtml(nameFor(b.type,b.id))}</b>. Recompile to apply.`);
354
+ await load();
355
+ }
356
+
357
+ async function deleteEdge(id){
358
+ const res = await fetch(API + "/api/edges/"+id,{method:"DELETE"});
359
+ if(!res.ok){ alert(await errorText(res)); }
360
+ await load();
361
+ }
362
+
363
+ async function addNode(type){
364
+ const name = prompt(`New ${type} name:`);
365
+ if(!name) return;
366
+ let parent_id = null;
367
+ if(NESTED.has(type)){
368
+ const p = prompt(`Parent ${type} name (optional — leave blank for top level):`);
369
+ if(p){ const match = GRAPH[byType(type)].find(n=>(n.name||"").toLowerCase()===p.trim().toLowerCase());
370
+ if(match) parent_id=match.id; else { alert(`No ${type} named "${p}" — creating at top level.`); } }
371
+ }
372
+ const res = await fetch(API + "/api/nodes/"+type,{method:"POST",headers:{"Content-Type":"application/json"},
373
+ body:JSON.stringify({name,parent_id})});
374
+ if(!res.ok){ alert(await errorText(res)); return; }
375
+ await load();
376
+ }
377
+
378
+ async function deleteNode(type,id){
379
+ if(!confirm(`Delete this ${type}? Its edges will be removed too, and any children become top-level.`)) return;
380
+ const res = await fetch(API + `/api/nodes/${type}/${id}`,{method:"DELETE"});
381
+ if(!res.ok){ alert(await errorText(res)); }
382
+ if(selection && selection.type===type && selection.id===id) selection=null;
383
+ await load();
384
+ }
385
+
386
+ async function compile(){
387
+ const btn = document.getElementById("btn-compile");
388
+ btn.disabled = true;
389
+ try{
390
+ const res = await fetch(API + "/api/compile",{method:"POST"});
391
+ if(!res.ok){ alert(await errorText(res)); return; }
392
+ const j = await res.json();
393
+ setHint(`Compiled <b>${j.count}</b> authorization rows.`);
394
+ } finally { btn.disabled = false; }
395
+ await load();
396
+ }
397
+
398
+ // ---- toolbar ----
399
+ document.getElementById("btn-connect").addEventListener("click", ()=>{
400
+ connectMode=!connectMode; connectFirst=null;
401
+ document.getElementById("btn-connect").classList.toggle("on",connectMode);
402
+ setHint(connectMode ? "Connect mode: click two records to draw an edge between them." : "Click any record to trace its relationships.");
403
+ render();
404
+ });
405
+ document.getElementById("btn-clear").addEventListener("click", ()=>{
406
+ selection=null; connectFirst=null; recompute(); render();
407
+ });
408
+ document.getElementById("btn-reload").addEventListener("click", load);
409
+ document.getElementById("btn-compile").addEventListener("click", compile);
410
+
411
+ // ---- utils ----
412
+ // Everything a row renders is searchable, including what the tooltip holds:
413
+ // a labelled node keeps its Type#id there, and pasting a uuid has to find it.
414
+ function haystack(n){ const ref=[n.external_type,n.external_id].filter(Boolean).join("#"); return [n.name,n.super_auth_label,ref].filter(Boolean).join(" ").toLowerCase(); }
415
+ function nameFor(t,id){ const x=GRAPH[byType(t)].find(n=>n.id===id); return x?(x.name ?? "#"+id):("#"+id); }
416
+ function setHint(html){ document.getElementById("hint").innerHTML=html; }
417
+ function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
418
+ async function errorText(res){ try{ const j=await res.json(); return j.error||("HTTP "+res.status); }catch(e){ return "HTTP "+res.status; } }
419
+
420
+ load();
421
+ </script>
422
+ </body>
423
+ </html>
@@ -0,0 +1,170 @@
1
+ require "super_auth"
2
+
3
+ module SuperAuth
4
+ class Editor
5
+ # Sample graph for the editor. "Acme Cloud": three departments that are
6
+ # deliberately DISJOINT. Engineering, Finance, and Support each have their
7
+ # own users, roles, permissions, and resources with no shared nodes, so
8
+ # traversal is obvious: click anyone in Engineering and only Engineering
9
+ # lights up.
10
+ #
11
+ # The hierarchy matches how grants inherit:
12
+ # - The shared "Developer" role is attached to the Engineering PARENT
13
+ # group, so every engineer (Backend + Frontend) inherits it, while
14
+ # Backend and Frontend each also hold a child-group grant the other
15
+ # does not.
16
+ # - "Support Lead" is the PARENT role of "Support Agent": a lead inherits
17
+ # the agent's abilities plus refunds; an agent does not get refunds.
18
+ #
19
+ # Special people:
20
+ # - Riley (Auditor): read-only into BOTH Finance and Support
21
+ # - Morgan (Admin): direct user->resource access across departments
22
+ # - Nina (New Hire): no access at all
23
+ #
24
+ # Destructive: run! replaces the whole graph, including the compiled
25
+ # authorizations. Only ever runs on request (super_auth-editor --seed).
26
+ module Seed
27
+ module_function
28
+
29
+ # Returns the row counts per table.
30
+ def run!
31
+ SuperAuth.db.transaction do
32
+ clear!
33
+
34
+ grp = SuperAuth::Group
35
+ rol = SuperAuth::Role
36
+ usr = SuperAuth::User
37
+ perm_m = SuperAuth::Permission
38
+ res_m = SuperAuth::Resource
39
+ edg = SuperAuth::Edge
40
+
41
+ # ===== GROUPS (Engineering is a parent of Backend + Frontend) =====
42
+ engineering = grp.create(name: "Engineering")
43
+ backend = grp.create(name: "Backend", parent_id: engineering.id)
44
+ frontend = grp.create(name: "Frontend", parent_id: engineering.id)
45
+ finance = grp.create(name: "Finance")
46
+ support = grp.create(name: "Customer Support")
47
+
48
+ # ===== ROLES (Support Lead is the parent of Support Agent) =====
49
+ developer = rol.create(name: "Developer")
50
+ sre = rol.create(name: "SRE")
51
+ accountant = rol.create(name: "Accountant")
52
+ support_lead = rol.create(name: "Support Lead")
53
+ support_agent = rol.create(name: "Support Agent", parent_id: support_lead.id)
54
+
55
+ # ===== PERMISSIONS (disjoint per department) =====
56
+ merge_code = perm_m.create(name: "merge_code")
57
+ read_repo = perm_m.create(name: "read_repo")
58
+ deploy = perm_m.create(name: "deploy")
59
+ run_migrations = perm_m.create(name: "run_migrations") # Backend-only
60
+ publish_site = perm_m.create(name: "publish_site") # Frontend-only
61
+ restart_server = perm_m.create(name: "restart_server") # SRE-only
62
+ view_ledger = perm_m.create(name: "view_ledger")
63
+ issue_invoice = perm_m.create(name: "issue_invoice")
64
+ run_payroll = perm_m.create(name: "run_payroll")
65
+ view_ticket = perm_m.create(name: "view_ticket")
66
+ close_ticket = perm_m.create(name: "close_ticket")
67
+ issue_refund = perm_m.create(name: "issue_refund") # Support Lead-only
68
+
69
+ # ===== RESOURCES (disjoint per department) =====
70
+ source_repo = res_m.create(name: "source_repo")
71
+ production_cluster = res_m.create(name: "production_cluster")
72
+ staging_cluster = res_m.create(name: "staging_cluster")
73
+ app_database = res_m.create(name: "app_database") # Backend
74
+ marketing_site = res_m.create(name: "marketing_site") # Frontend
75
+ general_ledger = res_m.create(name: "general_ledger")
76
+ invoices = res_m.create(name: "invoices")
77
+ support_tickets = res_m.create(name: "support_tickets")
78
+ customer_accounts = res_m.create(name: "customer_accounts")
79
+
80
+ # ===== USERS =====
81
+ alice = usr.create(name: "Alice") # Backend dev
82
+ bob = usr.create(name: "Bob") # Frontend dev
83
+ sam = usr.create(name: "Sam") # SRE
84
+ carol = usr.create(name: "Carol") # Accountant
85
+ dave = usr.create(name: "Dave") # Accountant
86
+ erin = usr.create(name: "Erin") # Support agent
87
+ frank = usr.create(name: "Frank") # Support lead
88
+ riley = usr.create(name: "Riley") # Auditor (cross-department, read-only)
89
+ morgan = usr.create(name: "Morgan") # Admin (direct resource access)
90
+ usr.create(name: "Nina") # New hire, no access yet
91
+
92
+ # ===== ENGINEERING =====
93
+ edg.create(user_id: alice.id, group_id: backend.id)
94
+ edg.create(user_id: bob.id, group_id: frontend.id)
95
+ # Shared Developer role on the PARENT group: both Alice and Bob inherit it
96
+ edg.create(group_id: engineering.id, role_id: developer.id)
97
+ edg.create(role_id: developer.id, permission_id: merge_code.id)
98
+ edg.create(role_id: developer.id, permission_id: read_repo.id)
99
+ edg.create(role_id: developer.id, permission_id: deploy.id)
100
+ edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
101
+ edg.create(permission_id: read_repo.id, resource_id: source_repo.id)
102
+ edg.create(permission_id: deploy.id, resource_id: production_cluster.id)
103
+ edg.create(permission_id: deploy.id, resource_id: staging_cluster.id)
104
+ # Child-group-specific grants (Alice gets one, Bob the other)
105
+ edg.create(group_id: backend.id, permission_id: run_migrations.id)
106
+ edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
107
+ edg.create(group_id: frontend.id, permission_id: publish_site.id)
108
+ edg.create(permission_id: publish_site.id, resource_id: marketing_site.id)
109
+ # Sam is an SRE via a direct role assignment
110
+ edg.create(user_id: sam.id, role_id: sre.id)
111
+ edg.create(role_id: sre.id, permission_id: restart_server.id)
112
+ edg.create(role_id: sre.id, permission_id: deploy.id)
113
+ edg.create(permission_id: restart_server.id, resource_id: production_cluster.id)
114
+
115
+ # ===== FINANCE =====
116
+ edg.create(user_id: carol.id, group_id: finance.id)
117
+ edg.create(user_id: dave.id, group_id: finance.id)
118
+ edg.create(group_id: finance.id, role_id: accountant.id)
119
+ edg.create(role_id: accountant.id, permission_id: view_ledger.id)
120
+ edg.create(role_id: accountant.id, permission_id: issue_invoice.id)
121
+ edg.create(role_id: accountant.id, permission_id: run_payroll.id)
122
+ edg.create(permission_id: view_ledger.id, resource_id: general_ledger.id)
123
+ edg.create(permission_id: issue_invoice.id, resource_id: invoices.id)
124
+ edg.create(permission_id: run_payroll.id, resource_id: general_ledger.id)
125
+
126
+ # ===== SUPPORT (Lead inherits Agent's abilities via the role hierarchy) =====
127
+ edg.create(user_id: erin.id, group_id: support.id)
128
+ edg.create(user_id: frank.id, group_id: support.id)
129
+ edg.create(user_id: frank.id, role_id: support_lead.id) # Frank is a lead
130
+ edg.create(group_id: support.id, role_id: support_agent.id) # everyone is at least an agent
131
+ edg.create(role_id: support_agent.id, permission_id: view_ticket.id)
132
+ edg.create(role_id: support_agent.id, permission_id: close_ticket.id)
133
+ edg.create(role_id: support_lead.id, permission_id: issue_refund.id)
134
+ edg.create(permission_id: view_ticket.id, resource_id: support_tickets.id)
135
+ edg.create(permission_id: close_ticket.id, resource_id: support_tickets.id)
136
+ edg.create(permission_id: issue_refund.id, resource_id: customer_accounts.id)
137
+
138
+ # ===== CROSS-CUTTERS =====
139
+ # Riley audits both the ledger and tickets (direct permission grants).
140
+ edg.create(user_id: riley.id, permission_id: view_ledger.id)
141
+ edg.create(user_id: riley.id, permission_id: view_ticket.id)
142
+ # Morgan has direct resource access across departments (simplest path).
143
+ edg.create(user_id: morgan.id, resource_id: production_cluster.id)
144
+ edg.create(user_id: morgan.id, resource_id: general_ledger.id)
145
+ edg.create(user_id: morgan.id, resource_id: support_tickets.id)
146
+
147
+ counts
148
+ end
149
+ end
150
+
151
+ # Empties the graph and the compiled table. Parents are detached first:
152
+ # MySQL checks the self-referencing key row by row.
153
+ def clear!
154
+ SuperAuth::Edge.dataset.delete
155
+ SuperAuth::Authorization.dataset.delete
156
+ [SuperAuth::Group, SuperAuth::Role].each { |m| m.dataset.update(parent_id: nil) }
157
+ [SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
158
+ m.dataset.delete
159
+ end
160
+ end
161
+
162
+ def counts
163
+ {
164
+ groups: SuperAuth::Group.count, roles: SuperAuth::Role.count, users: SuperAuth::User.count,
165
+ permissions: SuperAuth::Permission.count, resources: SuperAuth::Resource.count, edges: SuperAuth::Edge.count,
166
+ }
167
+ end
168
+ end
169
+ end
170
+ end