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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +132 -49
  6. data/USAGE.md +98 -31
  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/11_add_parent_id_to_resources.rb +32 -0
  10. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  11. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  12. data/db/seeds/sample_data.rb +1 -0
  13. data/exe/super_auth-editor +9 -0
  14. data/lib/generators/super_auth/install/templates/README +18 -11
  15. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  16. data/lib/super_auth/active_record/authorization.rb +7 -0
  17. data/lib/super_auth/active_record/by_current_user.rb +1 -1
  18. data/lib/super_auth/active_record/resource.rb +45 -0
  19. data/lib/super_auth/active_record/user.rb +3 -1
  20. data/lib/super_auth/authorization.rb +24 -0
  21. data/lib/super_auth/edge.rb +54 -18
  22. data/lib/super_auth/editor/cli.rb +91 -0
  23. data/lib/super_auth/editor/index.html +430 -0
  24. data/lib/super_auth/editor/seed.rb +176 -0
  25. data/lib/super_auth/editor.rb +288 -0
  26. data/lib/super_auth/nestable.rb +16 -3
  27. data/lib/super_auth/railtie.rb +9 -2
  28. data/lib/super_auth/resource.rb +57 -0
  29. data/lib/super_auth/rls.rb +164 -34
  30. data/lib/super_auth/user.rb +3 -1
  31. data/lib/super_auth/version.rb +1 -1
  32. data/lib/super_auth.rb +73 -5
  33. data/lib/tasks/super_auth_tasks.rake +28 -0
  34. metadata +13 -8
  35. data/VISUALIZATION.md +0 -58
  36. data/app/controllers/super_auth/graph_controller.rb +0 -654
  37. data/app/views/super_auth/graph/index.html.erb +0 -1408
  38. data/super_auth.gemspec +0 -35
  39. data/visualization.html +0 -747
@@ -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
- join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], id: Sequel[:permission_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], Sequel[:user_edges][:resource_id] => Sequel[:super_auth_resources][:id]).
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=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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>