super_auth 0.4.0 → 0.8.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 +90 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +132 -49
- data/USAGE.md +98 -31
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/seeds/sample_data.rb +1 -0
- data/exe/super_auth-editor +9 -0
- data/lib/generators/super_auth/install/templates/README +18 -11
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +7 -0
- data/lib/super_auth/active_record/by_current_user.rb +1 -1
- data/lib/super_auth/active_record/resource.rb +45 -0
- data/lib/super_auth/active_record/user.rb +3 -1
- data/lib/super_auth/authorization.rb +24 -0
- data/lib/super_auth/edge.rb +54 -18
- data/lib/super_auth/editor/cli.rb +91 -0
- data/lib/super_auth/editor/index.html +430 -0
- data/lib/super_auth/editor/seed.rb +176 -0
- data/lib/super_auth/editor.rb +288 -0
- data/lib/super_auth/nestable.rb +16 -3
- data/lib/super_auth/railtie.rb +9 -2
- data/lib/super_auth/resource.rb +57 -0
- 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 +73 -5
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +13 -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
data/lib/super_auth/edge.rb
CHANGED
|
@@ -39,6 +39,45 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
39
39
|
.union(users_resources)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Every resource node a grant reaches: each granted node paired with
|
|
43
|
+
# itself and each of its descendants, as (ancestor_id, descendant_id).
|
|
44
|
+
# Anchored on the ids that appear in edges rather than on the whole table.
|
|
45
|
+
# Groups and roles are few, but resources are one row per protected
|
|
46
|
+
# record, and the unanchored CTE materialises every pair of the table once
|
|
47
|
+
# per strategy — 27s per strategy on MySQL at 300k resources, 0.025s
|
|
48
|
+
# anchored — so the walk is sized by the grants, not by the table. On a
|
|
49
|
+
# flat graph it is the identity relation and the compiled rows are exactly
|
|
50
|
+
# what the previous pk join produced.
|
|
51
|
+
def resource_subtrees
|
|
52
|
+
granted = db[:super_auth_edges].exclude(resource_id: nil).select(:resource_id)
|
|
53
|
+
SuperAuth::Resource.descendant_pairs(of: granted)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The last hop of every strategy: from the resource id on an edge to the
|
|
57
|
+
# node it names and each node under it. The compiled row copies the
|
|
58
|
+
# descendant's own columns, so containment is not inheritance — a node
|
|
59
|
+
# keeps its own external_type — and the type-level tricks in ByCurrentUser
|
|
60
|
+
# still hold. No resource path columns, unlike groups and roles: nothing at
|
|
61
|
+
# runtime reads one, and super_auth_authorizations gains no columns.
|
|
62
|
+
def join_resource_subtree(ds, resource_id_column)
|
|
63
|
+
ds.
|
|
64
|
+
join(resource_subtrees.as(:resource_descendants), ancestor_id: resource_id_column).
|
|
65
|
+
join(Sequel[:super_auth_resources], id: Sequel[:resource_descendants][:descendant_id]).
|
|
66
|
+
# A (type, NULL) row — a wildcard, every record of its type — is only
|
|
67
|
+
# ever the node the grant named, never one reached through the tree.
|
|
68
|
+
# Resource.assert_compilable! refuses that shape loudly, but it is a
|
|
69
|
+
# separate statement from this one: under READ COMMITTED a write that
|
|
70
|
+
# nests a wildcard can land between the two, and the compiled table
|
|
71
|
+
# must not widen a container grant to a whole type because of it.
|
|
72
|
+
where(
|
|
73
|
+
Sequel.|(
|
|
74
|
+
{ Sequel[:resource_descendants][:ancestor_id] => Sequel[:resource_descendants][:descendant_id] },
|
|
75
|
+
{ Sequel[:super_auth_resources][:external_type] => nil },
|
|
76
|
+
Sequel.~(Sequel[:super_auth_resources][:external_id] => nil)
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
42
81
|
def users_groups_roles_permissions_resources
|
|
43
82
|
cast_type = string_cast_type
|
|
44
83
|
# Join users to their group via edges. group_ancestors pairs that group with itself and
|
|
@@ -46,7 +85,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
46
85
|
# expands the granted role to its whole subtree. Each step is correlated to the previous
|
|
47
86
|
# one, so a role held by one group never reaches members of an unrelated group. The tree
|
|
48
87
|
# CTEs (user_groups, granted_roles) are joined by id only to supply the path columns.
|
|
49
|
-
SuperAuth::User.db[:super_auth_users].
|
|
88
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
50
89
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
51
90
|
join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
|
|
52
91
|
join(Sequel[:super_auth_edges].as(:group_role_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
|
|
@@ -56,8 +95,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
56
95
|
join(SuperAuth::Role.from(SuperAuth::Role.trees).as(:granted_roles), Sequel[:granted_roles][:id] => Sequel[:role_descendants][:descendant_id]).
|
|
57
96
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
|
|
58
97
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
59
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
60
|
-
|
|
98
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
99
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).
|
|
61
100
|
select(
|
|
62
101
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
63
102
|
Sequel[:super_auth_users][:name].as(:user_name),
|
|
@@ -100,14 +139,14 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
100
139
|
# Join users to their group via edges. group_ancestors pairs that group with itself and
|
|
101
140
|
# every ancestor, so a group -> permission edge on any of them applies. user_groups (the
|
|
102
141
|
# tree) is joined by id only to supply the path columns.
|
|
103
|
-
SuperAuth::User.db[:super_auth_users].
|
|
142
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
104
143
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
105
144
|
join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
|
|
106
145
|
join(Sequel[:super_auth_edges].as(:group_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
|
|
107
146
|
join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:user_groups), Sequel[:user_groups][:id] => Sequel[:user_edges][:group_id]).
|
|
108
147
|
join(Sequel[:super_auth_permissions], id: Sequel[:group_edges][:permission_id]).
|
|
109
|
-
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
110
|
-
|
|
148
|
+
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
149
|
+
join_resource_subtree(ds, Sequel[:permission_edges][:resource_id]).
|
|
111
150
|
select(
|
|
112
151
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
113
152
|
Sequel[:super_auth_users][:name].as(:user_name),
|
|
@@ -150,7 +189,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
150
189
|
|
|
151
190
|
# Join users to the roles they hold directly. role_descendants expands each held role to
|
|
152
191
|
# its whole subtree; granted_roles (the tree) is joined by id only to supply the path columns.
|
|
153
|
-
SuperAuth::User.db[:super_auth_users].
|
|
192
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
154
193
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
155
194
|
where(Sequel.~(Sequel[:user_edges][:role_id] => nil)).
|
|
156
195
|
join(SuperAuth::Role.descendant_pairs.as(:role_descendants), ancestor_id: Sequel[:user_edges][:role_id]).
|
|
@@ -192,14 +231,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
192
231
|
# Join permission and resource edges on the expanded role
|
|
193
232
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
|
|
194
233
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
195
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
196
|
-
|
|
197
|
-
distinct
|
|
234
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
235
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
|
|
198
236
|
end
|
|
199
237
|
|
|
200
238
|
def users_permissions_resources
|
|
201
239
|
cast_type = string_cast_type
|
|
202
|
-
SuperAuth::User.
|
|
240
|
+
ds = SuperAuth::User.
|
|
203
241
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
204
242
|
select(
|
|
205
243
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
@@ -237,14 +275,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
237
275
|
).
|
|
238
276
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:user_id] => Sequel[:super_auth_users][:id]).
|
|
239
277
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
240
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
241
|
-
|
|
242
|
-
distinct
|
|
278
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
279
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
|
|
243
280
|
end
|
|
244
281
|
|
|
245
282
|
def users_resources
|
|
246
283
|
cast_type = string_cast_type
|
|
247
|
-
SuperAuth::User.
|
|
284
|
+
ds = SuperAuth::User.
|
|
248
285
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
249
286
|
select(
|
|
250
287
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
@@ -279,9 +316,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
279
316
|
Sequel[:super_auth_resources][:name].as(:resource_name),
|
|
280
317
|
Sequel[:super_auth_resources][:external_id].as(:resource_external_id),
|
|
281
318
|
Sequel[:super_auth_resources][:external_type].as(:resource_external_type)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
distinct
|
|
319
|
+
)
|
|
320
|
+
join_resource_subtree(ds, Sequel[:user_edges][:resource_id]).distinct
|
|
285
321
|
end
|
|
286
322
|
end
|
|
287
323
|
|
|
@@ -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,430 @@
|
|
|
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;grid-template-rows:1fr;
|
|
57
|
+
grid-template-areas:"resource"}
|
|
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-head{display:flex;align-items:center;gap:9px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
|
|
67
|
+
.box-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none;box-shadow:0 0 10px -2px var(--accent)}
|
|
68
|
+
.box-head .title{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.02em}
|
|
69
|
+
.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}
|
|
70
|
+
.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}
|
|
71
|
+
.box-head .add:hover{border-color:var(--accent);color:var(--accent)}
|
|
72
|
+
.filter{margin:8px 10px 6px;flex:none}
|
|
73
|
+
.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)}
|
|
74
|
+
.filter input::placeholder{color:var(--ink-faint)}
|
|
75
|
+
.filter input:focus{outline:none;border-color:var(--accent)}
|
|
76
|
+
.list{flex:1;overflow-y:auto;padding:2px 8px 10px}
|
|
77
|
+
.list::-webkit-scrollbar{width:9px}
|
|
78
|
+
.list::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
|
|
79
|
+
|
|
80
|
+
.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}
|
|
81
|
+
.item:hover{background:var(--panel-2);color:var(--ink)}
|
|
82
|
+
.item .name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
83
|
+
.item .ext{flex:none;color:var(--ink-faint);font-family:var(--mono);font-size:11px}
|
|
84
|
+
.item .tick{width:7px;height:7px;border-radius:50%;background:var(--accent);opacity:0;flex:none}
|
|
85
|
+
.item.selected{background:color-mix(in srgb,var(--accent) 22%,transparent);border-color:var(--accent);color:var(--ink);font-weight:600}
|
|
86
|
+
.item.selected .tick{opacity:1}
|
|
87
|
+
.item.connect-first{border-color:var(--brass);box-shadow:0 0 0 1px var(--brass) inset}
|
|
88
|
+
.item .del{opacity:0;background:none;border:none;color:var(--ink-faint);font-size:14px;line-height:1;padding:0 3px}
|
|
89
|
+
.item:hover .del{opacity:1}
|
|
90
|
+
.item .del:hover{color:var(--c-resource)}
|
|
91
|
+
.item .depth{flex:none;color:var(--ink-faint);font-family:var(--mono);font-size:11px}
|
|
92
|
+
.empty{color:var(--ink-faint);font-size:12px;font-family:var(--mono);padding:14px 8px;text-align:center}
|
|
93
|
+
.connectable .item{cursor:crosshair}
|
|
94
|
+
</style>
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<header>
|
|
98
|
+
<div class="brand"><span class="dot"></span>super_auth · graph editor</div>
|
|
99
|
+
<div class="tools">
|
|
100
|
+
<button class="tbtn" id="btn-connect">+ Connect mode</button>
|
|
101
|
+
<button class="tbtn" id="btn-clear">Clear selection</button>
|
|
102
|
+
<button class="tbtn" id="btn-reload">↻ Reload</button>
|
|
103
|
+
</div>
|
|
104
|
+
<div class="spacer"></div>
|
|
105
|
+
<div class="hint" id="hint">Click any record to trace its relationships across every box.</div>
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
<div id="compiled">
|
|
109
|
+
<span>Runtime access comes from the compiled authorizations table: <b id="compiled-count">…</b> rows. Edits here take effect after a recompile.</span>
|
|
110
|
+
<button class="tbtn" id="btn-compile">Recompile</button>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div id="inspector"></div>
|
|
114
|
+
|
|
115
|
+
<main>
|
|
116
|
+
<section class="region top">
|
|
117
|
+
<div class="box" data-area="group" data-type="group"></div>
|
|
118
|
+
<div class="box" data-area="role" data-type="role"></div>
|
|
119
|
+
<div class="box" data-area="user" data-type="user"></div>
|
|
120
|
+
<div class="box" data-area="permission" data-type="permission"></div>
|
|
121
|
+
</section>
|
|
122
|
+
<section class="region bottom">
|
|
123
|
+
<div class="box" data-area="resource" data-type="resource"></div>
|
|
124
|
+
</section>
|
|
125
|
+
</main>
|
|
126
|
+
|
|
127
|
+
<script>
|
|
128
|
+
// The app may be mounted under a prefix; every API call is relative to this page.
|
|
129
|
+
const API = location.pathname.replace(/\/$/, "");
|
|
130
|
+
const TITLES = {group:"Groups", role:"Roles", user:"Users", permission:"Permissions", resource:"Resources"};
|
|
131
|
+
const NESTED = new Set(["group","role","resource"]);
|
|
132
|
+
|
|
133
|
+
let GRAPH = null; // {groups,roles,users,permissions,resources,edges,authorizations_count}
|
|
134
|
+
let selection = null; // {type,id}
|
|
135
|
+
let relevant = null; // Set of "type:id" or null
|
|
136
|
+
let connectMode = false;
|
|
137
|
+
let connectFirst = null; // {type,id}
|
|
138
|
+
const boxFilters = {}; // area -> text
|
|
139
|
+
|
|
140
|
+
const boxes = [...document.querySelectorAll(".box")];
|
|
141
|
+
const byType = t => ({group:"groups",role:"roles",user:"users",permission:"permissions",resource:"resources"}[t]);
|
|
142
|
+
const key = (t,id) => t+":"+id;
|
|
143
|
+
|
|
144
|
+
// ---- data ----
|
|
145
|
+
async function load(){
|
|
146
|
+
const r = await fetch(API + "/api/graph");
|
|
147
|
+
if(!r.ok){ setHint(`<span style="color:var(--c-resource)">could not load the graph (HTTP ${r.status})</span>`); return; }
|
|
148
|
+
GRAPH = await r.json();
|
|
149
|
+
document.getElementById("compiled-count").textContent = GRAPH.authorizations_count;
|
|
150
|
+
recompute();
|
|
151
|
+
render();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---- directional traversal ----
|
|
155
|
+
// Authorization flows user → group → role → permission → resource. From the
|
|
156
|
+
// selected node we collect everything DOWNSTREAM (what it can reach) and
|
|
157
|
+
// everything UPSTREAM (what can reach it). So a user shows what they can access;
|
|
158
|
+
// a resource shows who can access it. The group, role and resource trees are
|
|
159
|
+
// each folded in as one more directed hop (see buildDirected).
|
|
160
|
+
const RANK = {user:0, group:1, role:2, permission:3, resource:4};
|
|
161
|
+
|
|
162
|
+
function buildDirected(){
|
|
163
|
+
const fwd = new Map(); // toward resources
|
|
164
|
+
const bwd = new Map(); // toward users
|
|
165
|
+
const add = (m,a,b) => { if(!m.has(a)) m.set(a,new Set()); m.get(a).add(b); };
|
|
166
|
+
const dir = (a,b) => { add(fwd,a,b); add(bwd,b,a); }; // a is upstream of b
|
|
167
|
+
for(const e of GRAPH.edges){
|
|
168
|
+
const ends = [];
|
|
169
|
+
if(e.user_id) ends.push(["user",e.user_id]);
|
|
170
|
+
if(e.group_id) ends.push(["group",e.group_id]);
|
|
171
|
+
if(e.role_id) ends.push(["role",e.role_id]);
|
|
172
|
+
if(e.permission_id) ends.push(["permission",e.permission_id]);
|
|
173
|
+
if(e.resource_id) ends.push(["resource",e.resource_id]);
|
|
174
|
+
for(let i=0;i<ends.length;i++) for(let j=i+1;j<ends.length;j++){
|
|
175
|
+
const [ta,ia]=ends[i], [tb,ib]=ends[j];
|
|
176
|
+
const a=key(ta,ia), b=key(tb,ib);
|
|
177
|
+
if(RANK[ta] <= RANK[tb]) dir(a,b); else dir(b,a);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Hierarchy — directional, so siblings never leak:
|
|
181
|
+
// • Group grants flow DOWN to members of descendant groups, so a member
|
|
182
|
+
// inherits ANCESTOR grants → a child group is upstream of its parent.
|
|
183
|
+
// • Role grants flow to DESCENDANT roles, so holding a parent role includes
|
|
184
|
+
// its children → a parent role is upstream of its child.
|
|
185
|
+
// • Resource grants reach DESCENDANT resources: a grant on a container
|
|
186
|
+
// covers everything under it → a parent resource is upstream of its
|
|
187
|
+
// children, the same direction as roles and the opposite of groups.
|
|
188
|
+
// (Selecting a child group therefore reaches its ancestors, never its siblings.)
|
|
189
|
+
for(const g of GRAPH.groups) if(g.parent_id) dir(key("group",g.id), key("group",g.parent_id));
|
|
190
|
+
for(const r of GRAPH.roles) if(r.parent_id) dir(key("role",r.parent_id), key("role",r.id));
|
|
191
|
+
for(const r of GRAPH.resources) if(r.parent_id) dir(key("resource",r.parent_id), key("resource",r.id));
|
|
192
|
+
return {fwd,bwd};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function reach(map,start){
|
|
196
|
+
const seen=new Set([start]), q=[start];
|
|
197
|
+
while(q.length){ const c=q.shift(); for(const nb of (map.get(c)||[])) if(!seen.has(nb)){ seen.add(nb); q.push(nb); } }
|
|
198
|
+
return seen;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function recompute(){
|
|
202
|
+
if(!selection){ relevant = null; return; }
|
|
203
|
+
const {fwd,bwd} = buildDirected();
|
|
204
|
+
const start = key(selection.type, selection.id);
|
|
205
|
+
const down = reach(fwd,start); // what it can reach
|
|
206
|
+
const up = reach(bwd,start); // what can reach it
|
|
207
|
+
const rel = new Set([...down, ...up]);
|
|
208
|
+
// Also surface "peer" users — people whose own access overlaps this subgraph —
|
|
209
|
+
// so selecting a user reveals teammates, not just themselves. (Users are pure
|
|
210
|
+
// source nodes, so they'd never appear otherwise.)
|
|
211
|
+
for(const u of GRAPH.users){
|
|
212
|
+
const uk = key("user", u.id);
|
|
213
|
+
if(rel.has(uk)) continue;
|
|
214
|
+
for(const n of reach(fwd, uk)){ if(rel.has(n)){ rel.add(uk); break; } }
|
|
215
|
+
}
|
|
216
|
+
relevant = rel;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// depth for nested types (for indentation)
|
|
220
|
+
function depthOf(type, node){
|
|
221
|
+
const list = GRAPH[byType(type)];
|
|
222
|
+
const map = new Map(list.map(n=>[n.id,n]));
|
|
223
|
+
let d=0, cur=node;
|
|
224
|
+
while(cur && cur.parent_id && map.has(cur.parent_id)){ d++; cur=map.get(cur.parent_id); if(d>20)break; }
|
|
225
|
+
return d;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- rendering ----
|
|
229
|
+
function render(){
|
|
230
|
+
for(const box of boxes){
|
|
231
|
+
const type = box.dataset.type;
|
|
232
|
+
const area = box.dataset.area;
|
|
233
|
+
let list = GRAPH[byType(type)] || [];
|
|
234
|
+
|
|
235
|
+
// graph-traversal relevance filter (from selection)
|
|
236
|
+
if(relevant) list = list.filter(n => relevant.has(key(type,n.id)));
|
|
237
|
+
// per-box text filter
|
|
238
|
+
const f = (boxFilters[area]||"").toLowerCase();
|
|
239
|
+
let shown = f ? list.filter(n => haystack(n).includes(f)) : list;
|
|
240
|
+
|
|
241
|
+
const total = GRAPH[byType(type)].length;
|
|
242
|
+
const countTxt = (relevant || f) ? `${shown.length}/${total}` : `${total}`;
|
|
243
|
+
|
|
244
|
+
let items = shown.map(n => {
|
|
245
|
+
const isSel = selection && selection.type===type && selection.id===n.id;
|
|
246
|
+
const isCF = connectFirst && connectFirst.type===type && connectFirst.id===n.id;
|
|
247
|
+
const depth = NESTED.has(type) ? depthOf(type,n) : 0;
|
|
248
|
+
const pad = depth ? `style="padding-left:${8+depth*14}px"` : "";
|
|
249
|
+
const arrow = depth ? `<span class="depth">${"└".padStart(1)} </span>` : "";
|
|
250
|
+
const name = n.name ?? "";
|
|
251
|
+
// The external record occupies one slot: its label when the graph
|
|
252
|
+
// stored one, otherwise the Type#id that used to be the only
|
|
253
|
+
// rendering. Either way the other half is the tooltip. A type with no
|
|
254
|
+
// id is the deprecated type-level (wildcard) node — every record of
|
|
255
|
+
// the type — and its tooltip says so, since Type#* alone reads as a
|
|
256
|
+
// typo.
|
|
257
|
+
const wildcard = type === "resource" && !!n.external_type && n.external_id == null;
|
|
258
|
+
const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
|
|
259
|
+
const kind = wildcard ? "type-level (deprecated)" : "external record";
|
|
260
|
+
const ext = !ref ? "" : (n.super_auth_label
|
|
261
|
+
? `<span class="ext" title="${wildcard ? `${ref} · ${kind}` : ref}">${escapeHtml(n.super_auth_label)}</span>`
|
|
262
|
+
: `<span class="ext" title="${kind}">${ref}</span>`);
|
|
263
|
+
return `<div class="item ${isSel?'selected':''} ${isCF?'connect-first':''}" data-id="${n.id}" ${pad}>
|
|
264
|
+
<span class="tick"></span>
|
|
265
|
+
${arrow}<span class="name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>${ext}
|
|
266
|
+
<button class="del" data-del="${n.id}" title="Delete">✕</button>
|
|
267
|
+
</div>`;
|
|
268
|
+
}).join("");
|
|
269
|
+
if(!shown.length) items = `<div class="empty">${relevant ? "no related "+TITLES[type].toLowerCase() : "empty"}</div>`;
|
|
270
|
+
|
|
271
|
+
box.innerHTML = `
|
|
272
|
+
<div class="box-head">
|
|
273
|
+
<span class="swatch"></span>
|
|
274
|
+
<span class="title">${TITLES[type]}</span>
|
|
275
|
+
<span class="count">${countTxt}</span>
|
|
276
|
+
<button class="add" title="Add ${TITLES[type]}">+</button>
|
|
277
|
+
</div>
|
|
278
|
+
<div class="filter"><input type="text" placeholder="filter ${TITLES[type].toLowerCase()}…" value="${escapeHtml(boxFilters[area]||'')}"></div>
|
|
279
|
+
<div class="list ${connectMode?'connectable':''}">${items}</div>`;
|
|
280
|
+
|
|
281
|
+
// wire events
|
|
282
|
+
const input = box.querySelector(".filter input");
|
|
283
|
+
input.addEventListener("input", e => { boxFilters[area]=e.target.value; render(); reFocus(box); });
|
|
284
|
+
box.querySelector(".add").addEventListener("click", () => addNode(type));
|
|
285
|
+
box.querySelectorAll(".item").forEach(el=>{
|
|
286
|
+
el.addEventListener("click", ev=>{
|
|
287
|
+
if(ev.target.closest(".del")) return;
|
|
288
|
+
onItemClick(type, +el.dataset.id);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
box.querySelectorAll(".del").forEach(el=>{
|
|
292
|
+
el.addEventListener("click", ()=> deleteNode(type, +el.dataset.del));
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
renderInspector();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let focusArea=null;
|
|
299
|
+
function reFocus(box){ // keep caret in the filter after re-render
|
|
300
|
+
const area=box.dataset.area;
|
|
301
|
+
const input=document.querySelector(`.box[data-area="${area}"] .filter input`);
|
|
302
|
+
if(input){ input.focus(); const v=input.value; input.setSelectionRange(v.length,v.length); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function renderInspector(){
|
|
306
|
+
const ins = document.getElementById("inspector");
|
|
307
|
+
if(!selection){ ins.classList.remove("show"); ins.innerHTML=""; return; }
|
|
308
|
+
ins.classList.add("show");
|
|
309
|
+
const node = GRAPH[byType(selection.type)].find(n=>n.id===selection.id);
|
|
310
|
+
if(!node){ ins.classList.remove("show"); return; }
|
|
311
|
+
const color = {group:"var(--c-group)",role:"var(--c-role)",user:"var(--c-user)",permission:"var(--c-perm)",resource:"var(--c-resource)"}[selection.type];
|
|
312
|
+
|
|
313
|
+
// relevance counts per type (excluding the selected type's self)
|
|
314
|
+
const counts = ["group","role","user","permission","resource"].map(t=>{
|
|
315
|
+
const n = GRAPH[byType(t)].filter(x=>relevant.has(key(t,x.id)) && !(t===selection.type && x.id===selection.id)).length;
|
|
316
|
+
return `<span><b>${n}</b> ${TITLES[t].toLowerCase()}</span>`;
|
|
317
|
+
}).join("");
|
|
318
|
+
|
|
319
|
+
// direct edges of the selected node (editable)
|
|
320
|
+
const col = {user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[selection.type];
|
|
321
|
+
const direct = GRAPH.edges.filter(e=>e[col]===selection.id);
|
|
322
|
+
const edgeChips = direct.map(e=>{
|
|
323
|
+
const other = ["user","group","role","permission","resource"].filter(t=>{
|
|
324
|
+
const c={user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[t];
|
|
325
|
+
return e[c] && !(t===selection.type && e[c]===selection.id);
|
|
326
|
+
}).map(t=>{
|
|
327
|
+
const c={user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[t];
|
|
328
|
+
return `${escapeHtml(nameFor(t,e[c]))} <span style="color:var(--ink-faint)">(${t})</span>`;
|
|
329
|
+
}).join(", ");
|
|
330
|
+
return `<span class="edge-chip">→ ${other||"—"} <button class="x" data-edge="${e.id}" title="Delete edge">✕</button></span>`;
|
|
331
|
+
}).join("");
|
|
332
|
+
|
|
333
|
+
ins.innerHTML = `
|
|
334
|
+
<span class="sel-chip"><span class="swatch" style="background:${color}"></span>${escapeHtml(node.name ?? "")}
|
|
335
|
+
<span style="color:var(--ink-faint)">${selection.type}</span></span>
|
|
336
|
+
<span class="rel-counts">${counts}</span>
|
|
337
|
+
<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>`;
|
|
338
|
+
ins.querySelectorAll("[data-edge]").forEach(b=> b.addEventListener("click", ()=> deleteEdge(+b.dataset.edge)));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- interactions ----
|
|
342
|
+
function onItemClick(type,id){
|
|
343
|
+
if(connectMode){
|
|
344
|
+
if(!connectFirst){ connectFirst={type,id}; setHint(`Connecting from <b>${escapeHtml(nameFor(type,id))}</b> — pick a second record.`); render(); return; }
|
|
345
|
+
if(connectFirst.type===type && connectFirst.id===id){ connectFirst=null; setHint("Connect mode: pick the first record."); render(); return; }
|
|
346
|
+
createEdge(connectFirst, {type,id});
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if(selection && selection.type===type && selection.id===id){ selection=null; } // toggle off
|
|
350
|
+
else { selection={type,id}; }
|
|
351
|
+
recompute(); render();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function createEdge(a,b){
|
|
355
|
+
const res = await fetch(API + "/api/edges",{method:"POST",headers:{"Content-Type":"application/json"},
|
|
356
|
+
body:JSON.stringify({a_type:a.type,a_id:a.id,b_type:b.type,b_id:b.id})});
|
|
357
|
+
const j = await res.json();
|
|
358
|
+
connectFirst=null;
|
|
359
|
+
if(!res.ok){ setHint(`<span style="color:var(--c-resource)">${escapeHtml(j.error||"could not connect")}</span>`); }
|
|
360
|
+
else setHint(`Connected <b>${escapeHtml(nameFor(a.type,a.id))}</b> → <b>${escapeHtml(nameFor(b.type,b.id))}</b>. Recompile to apply.`);
|
|
361
|
+
await load();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function deleteEdge(id){
|
|
365
|
+
const res = await fetch(API + "/api/edges/"+id,{method:"DELETE"});
|
|
366
|
+
if(!res.ok){ alert(await errorText(res)); }
|
|
367
|
+
await load();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function addNode(type){
|
|
371
|
+
const name = prompt(`New ${type} name:`);
|
|
372
|
+
if(!name) return;
|
|
373
|
+
let parent_id = null;
|
|
374
|
+
if(NESTED.has(type)){
|
|
375
|
+
const p = prompt(`Parent ${type} name (optional — leave blank for top level):`);
|
|
376
|
+
if(p){ const match = GRAPH[byType(type)].find(n=>(n.name||"").toLowerCase()===p.trim().toLowerCase());
|
|
377
|
+
if(match) parent_id=match.id; else { alert(`No ${type} named "${p}" — creating at top level.`); } }
|
|
378
|
+
}
|
|
379
|
+
const res = await fetch(API + "/api/nodes/"+type,{method:"POST",headers:{"Content-Type":"application/json"},
|
|
380
|
+
body:JSON.stringify({name,parent_id})});
|
|
381
|
+
if(!res.ok){ alert(await errorText(res)); return; }
|
|
382
|
+
await load();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function deleteNode(type,id){
|
|
386
|
+
if(!confirm(`Delete this ${type}? Its edges will be removed too, and any children become top-level.`)) return;
|
|
387
|
+
const res = await fetch(API + `/api/nodes/${type}/${id}`,{method:"DELETE"});
|
|
388
|
+
if(!res.ok){ alert(await errorText(res)); }
|
|
389
|
+
if(selection && selection.type===type && selection.id===id) selection=null;
|
|
390
|
+
await load();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function compile(){
|
|
394
|
+
const btn = document.getElementById("btn-compile");
|
|
395
|
+
btn.disabled = true;
|
|
396
|
+
try{
|
|
397
|
+
const res = await fetch(API + "/api/compile",{method:"POST"});
|
|
398
|
+
if(!res.ok){ alert(await errorText(res)); return; }
|
|
399
|
+
const j = await res.json();
|
|
400
|
+
setHint(`Compiled <b>${j.count}</b> authorization rows.`);
|
|
401
|
+
} finally { btn.disabled = false; }
|
|
402
|
+
await load();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- toolbar ----
|
|
406
|
+
document.getElementById("btn-connect").addEventListener("click", ()=>{
|
|
407
|
+
connectMode=!connectMode; connectFirst=null;
|
|
408
|
+
document.getElementById("btn-connect").classList.toggle("on",connectMode);
|
|
409
|
+
setHint(connectMode ? "Connect mode: click two records to draw an edge between them." : "Click any record to trace its relationships.");
|
|
410
|
+
render();
|
|
411
|
+
});
|
|
412
|
+
document.getElementById("btn-clear").addEventListener("click", ()=>{
|
|
413
|
+
selection=null; connectFirst=null; recompute(); render();
|
|
414
|
+
});
|
|
415
|
+
document.getElementById("btn-reload").addEventListener("click", load);
|
|
416
|
+
document.getElementById("btn-compile").addEventListener("click", compile);
|
|
417
|
+
|
|
418
|
+
// ---- utils ----
|
|
419
|
+
// Everything a row renders is searchable, including what the tooltip holds:
|
|
420
|
+
// a labelled node keeps its Type#id there, and pasting a uuid has to find it.
|
|
421
|
+
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(); }
|
|
422
|
+
function nameFor(t,id){ const x=GRAPH[byType(t)].find(n=>n.id===id); return x?(x.name ?? "#"+id):("#"+id); }
|
|
423
|
+
function setHint(html){ document.getElementById("hint").innerHTML=html; }
|
|
424
|
+
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
|
425
|
+
async function errorText(res){ try{ const j=await res.json(); return j.error||("HTTP "+res.status); }catch(e){ return "HTTP "+res.status; } }
|
|
426
|
+
|
|
427
|
+
load();
|
|
428
|
+
</script>
|
|
429
|
+
</body>
|
|
430
|
+
</html>
|