super_auth 0.4.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +97 -31
- data/USAGE.md +17 -15
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
- data/exe/super_auth-editor +9 -0
- data/lib/generators/super_auth/install/templates/README +15 -10
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/by_current_user.rb +1 -1
- data/lib/super_auth/active_record/resource.rb +41 -0
- data/lib/super_auth/active_record/user.rb +3 -1
- data/lib/super_auth/authorization.rb +12 -0
- data/lib/super_auth/editor/cli.rb +91 -0
- data/lib/super_auth/editor/index.html +423 -0
- data/lib/super_auth/editor/seed.rb +170 -0
- data/lib/super_auth/editor.rb +273 -0
- data/lib/super_auth/railtie.rb +0 -2
- data/lib/super_auth/rls.rb +164 -34
- data/lib/super_auth/user.rb +3 -1
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +42 -5
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +11 -8
- data/VISUALIZATION.md +0 -58
- data/app/controllers/super_auth/graph_controller.rb +0 -654
- data/app/views/super_auth/graph/index.html.erb +0 -1408
- data/super_auth.gemspec +0 -35
- data/visualization.html +0 -747
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "super_auth/editor"
|
|
3
|
+
|
|
4
|
+
module SuperAuth
|
|
5
|
+
class Editor
|
|
6
|
+
# Boots the editor from the command line: connect, optionally migrate and
|
|
7
|
+
# seed, then serve on loopback. See exe/super_auth-editor.
|
|
8
|
+
module CLI
|
|
9
|
+
DEFAULTS = { host: "127.0.0.1", port: 4666, migrate: false, seed: false }.freeze
|
|
10
|
+
LOOPBACK_BINDS = %w[127.0.0.1 localhost ::1].freeze
|
|
11
|
+
# Host headers accepted when bound to loopback (DNS-rebinding defence).
|
|
12
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
|
|
13
|
+
WARNING = "WARNING: the editor has no authentication. " \
|
|
14
|
+
"Anyone who can reach this port can rewrite the authorization graph.".freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def parse(argv, out: $stdout)
|
|
19
|
+
options = DEFAULTS.dup
|
|
20
|
+
parser = OptionParser.new do |o|
|
|
21
|
+
o.banner = "Usage: super_auth-editor [options]"
|
|
22
|
+
o.on("-H", "--host HOST", "bind address (default #{DEFAULTS[:host]})") { |v| options[:host] = v }
|
|
23
|
+
o.on("-p", "--port PORT", Integer, "port (default #{DEFAULTS[:port]})") { |v| options[:port] = v }
|
|
24
|
+
o.on("--migrate", "run the super_auth Sequel migrations before starting") { options[:migrate] = true }
|
|
25
|
+
o.on("--seed", "replace the whole graph with the Acme Cloud sample (destructive)") { options[:seed] = true }
|
|
26
|
+
o.on("-v", "--version", "print the version") do
|
|
27
|
+
out.puts SuperAuth::VERSION
|
|
28
|
+
exit
|
|
29
|
+
end
|
|
30
|
+
o.on("-h", "--help", "show this help") do
|
|
31
|
+
out.puts o
|
|
32
|
+
out.puts "Reads SUPER_AUTH_DATABASE_URL (required)."
|
|
33
|
+
out.puts WARNING
|
|
34
|
+
exit
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
parser.parse!(argv.dup)
|
|
38
|
+
options
|
|
39
|
+
rescue OptionParser::ParseError => e
|
|
40
|
+
raise SuperAuth::Error, "#{e.message}\n#{parser}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run(argv, env: ENV, err: $stderr)
|
|
44
|
+
options = parse(argv)
|
|
45
|
+
url = env["SUPER_AUTH_DATABASE_URL"].to_s.strip
|
|
46
|
+
if url.empty?
|
|
47
|
+
raise SuperAuth::Error, "SUPER_AUTH_DATABASE_URL is not set. Point it at the database that holds " \
|
|
48
|
+
"the super_auth tables, e.g. postgres://user:password@localhost/app_development"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# First Sequel::Database in the process: the models bind to it.
|
|
52
|
+
begin
|
|
53
|
+
SuperAuth.db = Sequel.connect(url)
|
|
54
|
+
rescue Sequel::AdapterNotFound => e
|
|
55
|
+
raise SuperAuth::Error, "#{e.message}. Install the adapter gem for this URL (pg, mysql2 or sqlite3)."
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
SuperAuth.install_migrations if options[:migrate]
|
|
59
|
+
begin
|
|
60
|
+
SuperAuth.load
|
|
61
|
+
rescue Sequel::DatabaseError => e
|
|
62
|
+
raise SuperAuth::Error, "super_auth tables not found at #{redact(url)} (#{e.message.lines.first.to_s.strip}). " \
|
|
63
|
+
"Run with --migrate to create them, or run your application's migrations."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if options[:seed]
|
|
67
|
+
require "super_auth/editor/seed"
|
|
68
|
+
counts = SuperAuth::Editor::Seed.run!
|
|
69
|
+
err.puts "Seeded the Acme Cloud sample graph: #{counts.map { |k, v| "#{v} #{k}" }.join(', ')}."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
require "rackup"
|
|
74
|
+
rescue LoadError
|
|
75
|
+
raise SuperAuth::Error, "The editor needs a Rack server: gem install rackup webrick (puma also works)."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
loopback = LOOPBACK_BINDS.include?(options[:host])
|
|
79
|
+
err.puts WARNING
|
|
80
|
+
err.puts "Bound to #{options[:host]}, which other machines can reach." unless loopback
|
|
81
|
+
err.puts "Editor at http://#{options[:host]}:#{options[:port]}"
|
|
82
|
+
app = SuperAuth::Editor.new(hosts: loopback ? LOOPBACK_HOSTS : nil)
|
|
83
|
+
Rackup::Server.start(app: app, Host: options[:host], Port: options[:port], environment: "deployment")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def redact(url)
|
|
87
|
+
url.sub(%r{//([^:/@]+):[^@]*@}, '//\1:***@')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|