super_auth 0.7.0 → 0.9.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +440 -42
  5. data/USAGE.md +127 -21
  6. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  7. data/db/migrate/12_add_resource_indexes.rb +116 -0
  8. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  9. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  10. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  11. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  12. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  13. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  14. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  16. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  17. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  18. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  19. data/db/seeds/sample_data.rb +1 -0
  20. data/lib/generators/super_auth/install/templates/README +6 -2
  21. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  22. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  23. data/lib/super_auth/active_record/authorization.rb +14 -3
  24. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  25. data/lib/super_auth/active_record/group.rb +3 -0
  26. data/lib/super_auth/active_record/nested.rb +43 -0
  27. data/lib/super_auth/active_record/resource.rb +28 -4
  28. data/lib/super_auth/active_record/role.rb +3 -0
  29. data/lib/super_auth/active_record.rb +30 -2
  30. data/lib/super_auth/authorization.rb +63 -10
  31. data/lib/super_auth/edge.rb +73 -18
  32. data/lib/super_auth/editor/index.html +16 -10
  33. data/lib/super_auth/editor/seed.rb +11 -5
  34. data/lib/super_auth/editor.rb +35 -11
  35. data/lib/super_auth/nestable.rb +105 -4
  36. data/lib/super_auth/reach.rb +88 -0
  37. data/lib/super_auth/resource.rb +71 -0
  38. data/lib/super_auth/rls.rb +576 -44
  39. data/lib/super_auth/tree_guard.rb +115 -0
  40. data/lib/super_auth/version.rb +1 -1
  41. data/lib/super_auth.rb +55 -2
  42. metadata +10 -1
@@ -7,6 +7,19 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
7
7
  many_to_one :role
8
8
  many_to_one :resource
9
9
 
10
+ # The columns of `authorizations`, in its order. compile! inserts the union
11
+ # straight into super_auth_authorizations under this list, so it is the
12
+ # contract between the five SELECTs below and the table: MySQL and Postgres
13
+ # both need the column list, and the table has columns the union does not
14
+ # fill (its own timestamps, the ActiveRecord migration's id).
15
+ AUTHORIZATION_COLUMNS = %i[
16
+ user_id user_name user_external_id user_external_type user_created_at user_updated_at
17
+ group_id group_name group_path group_name_path group_parent_id group_created_at group_updated_at
18
+ role_id role_name role_path role_name_path role_parent_id role_created_at role_updated_at
19
+ permission_id permission_name permission_created_at permission_updated_at
20
+ resource_id resource_name resource_external_id resource_external_type
21
+ ].freeze
22
+
10
23
  class << self
11
24
  # The five strategies are UNIONed positionally. A column that is a real
12
25
  # text column in one strategy and CAST(NULL AS ...) in another must be cast
@@ -39,6 +52,51 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
39
52
  .union(users_resources)
40
53
  end
41
54
 
55
+ # Every resource node a grant reaches: each granted node paired with
56
+ # itself and each of its descendants, as (ancestor_id, descendant_id).
57
+ # Anchored on the ids that appear in edges rather than on the whole table.
58
+ # Groups and roles are few, but resources are one row per protected
59
+ # record, and the unanchored CTE materialises every pair of the table once
60
+ # per strategy — 27s per strategy on MySQL at 300k resources, 0.025s
61
+ # anchored — so the walk is sized by the grants, not by the table. On a
62
+ # flat graph it is the identity relation and the compiled rows are exactly
63
+ # what the previous pk join produced. The walk never descends from a
64
+ # type-level node (Resource.descend_from): a granted (type, NULL) node
65
+ # pairs with itself and nothing beneath it, on every path that reads this
66
+ # relation, compile! or not.
67
+ def resource_subtrees
68
+ granted = db[:super_auth_edges].exclude(resource_id: nil).select(:resource_id)
69
+ SuperAuth::Resource.descendant_pairs(of: granted)
70
+ end
71
+
72
+ # The last hop of every strategy: from the resource id on an edge to the
73
+ # node it names and each node under it. The compiled row copies the
74
+ # descendant's own columns, so containment is not inheritance — a node
75
+ # keeps its own external_type — and the type-level tricks in ByCurrentUser
76
+ # still hold. No resource path columns, unlike groups and roles: nothing at
77
+ # runtime reads one, and super_auth_authorizations gains no columns.
78
+ def join_resource_subtree(ds, resource_id_column)
79
+ ds.
80
+ join(resource_subtrees.as(:resource_descendants), ancestor_id: resource_id_column).
81
+ join(Sequel[:super_auth_resources], id: Sequel[:resource_descendants][:descendant_id]).
82
+ # A (type, NULL) row — a type-level node, every record of its type —
83
+ # is only ever the node the grant named, never one reached through
84
+ # the tree; the walk itself refuses the other direction, a node
85
+ # reached through a type-level parent. Resource.assert_compilable!
86
+ # refuses both shapes loudly, but it is a separate statement from this
87
+ # one: under READ COMMITTED a write that nests a type-level node can
88
+ # land between the two, and the compiled table must not widen a
89
+ # container grant to a whole type, or a type-level grant to the nodes
90
+ # under it, because of it.
91
+ where(
92
+ Sequel.|(
93
+ { Sequel[:resource_descendants][:ancestor_id] => Sequel[:resource_descendants][:descendant_id] },
94
+ { Sequel[:super_auth_resources][:external_type] => nil },
95
+ Sequel.~(Sequel[:super_auth_resources][:external_id] => nil)
96
+ )
97
+ )
98
+ end
99
+
42
100
  def users_groups_roles_permissions_resources
43
101
  cast_type = string_cast_type
44
102
  # Join users to their group via edges. group_ancestors pairs that group with itself and
@@ -46,7 +104,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
46
104
  # expands the granted role to its whole subtree. Each step is correlated to the previous
47
105
  # one, so a role held by one group never reaches members of an unrelated group. The tree
48
106
  # CTEs (user_groups, granted_roles) are joined by id only to supply the path columns.
49
- SuperAuth::User.db[:super_auth_users].
107
+ ds = SuperAuth::User.db[:super_auth_users].
50
108
  join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
51
109
  join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
52
110
  join(Sequel[:super_auth_edges].as(:group_role_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
@@ -56,8 +114,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
56
114
  join(SuperAuth::Role.from(SuperAuth::Role.trees).as(:granted_roles), Sequel[:granted_roles][:id] => Sequel[:role_descendants][:descendant_id]).
57
115
  join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
58
116
  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]).
117
+ join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
118
+ join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).
61
119
  select(
62
120
  Sequel[:super_auth_users][:id].as(:user_id),
63
121
  Sequel[:super_auth_users][:name].as(:user_name),
@@ -100,14 +158,14 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
100
158
  # Join users to their group via edges. group_ancestors pairs that group with itself and
101
159
  # every ancestor, so a group -> permission edge on any of them applies. user_groups (the
102
160
  # tree) is joined by id only to supply the path columns.
103
- SuperAuth::User.db[:super_auth_users].
161
+ ds = SuperAuth::User.db[:super_auth_users].
104
162
  join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
105
163
  join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
106
164
  join(Sequel[:super_auth_edges].as(:group_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
107
165
  join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:user_groups), Sequel[:user_groups][:id] => Sequel[:user_edges][:group_id]).
108
166
  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]).
167
+ join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
168
+ join_resource_subtree(ds, Sequel[:permission_edges][:resource_id]).
111
169
  select(
112
170
  Sequel[:super_auth_users][:id].as(:user_id),
113
171
  Sequel[:super_auth_users][:name].as(:user_name),
@@ -150,7 +208,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
150
208
 
151
209
  # Join users to the roles they hold directly. role_descendants expands each held role to
152
210
  # its whole subtree; granted_roles (the tree) is joined by id only to supply the path columns.
153
- SuperAuth::User.db[:super_auth_users].
211
+ ds = SuperAuth::User.db[:super_auth_users].
154
212
  join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
155
213
  where(Sequel.~(Sequel[:user_edges][:role_id] => nil)).
156
214
  join(SuperAuth::Role.descendant_pairs.as(:role_descendants), ancestor_id: Sequel[:user_edges][:role_id]).
@@ -192,14 +250,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
192
250
  # Join permission and resource edges on the expanded role
193
251
  join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
194
252
  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
253
+ join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
254
+ join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
198
255
  end
199
256
 
200
257
  def users_permissions_resources
201
258
  cast_type = string_cast_type
202
- SuperAuth::User.
259
+ ds = SuperAuth::User.
203
260
  join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
204
261
  select(
205
262
  Sequel[:super_auth_users][:id].as(:user_id),
@@ -237,14 +294,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
237
294
  ).
238
295
  join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:user_id] => Sequel[:super_auth_users][:id]).
239
296
  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
297
+ join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
298
+ join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
243
299
  end
244
300
 
245
301
  def users_resources
246
302
  cast_type = string_cast_type
247
- SuperAuth::User.
303
+ ds = SuperAuth::User.
248
304
  join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
249
305
  select(
250
306
  Sequel[:super_auth_users][:id].as(:user_id),
@@ -279,9 +335,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
279
335
  Sequel[:super_auth_resources][:name].as(:resource_name),
280
336
  Sequel[:super_auth_resources][:external_id].as(:resource_external_id),
281
337
  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
338
+ )
339
+ join_resource_subtree(ds, Sequel[:user_edges][:resource_id]).distinct
285
340
  end
286
341
  end
287
342
 
@@ -53,8 +53,8 @@
53
53
  .region{display:grid;gap:10px;min-height:0}
54
54
  .region.top{flex:1.15;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;
55
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"}
56
+ .region.bottom{flex:.85;grid-template-columns:1fr;grid-template-rows:1fr;
57
+ grid-template-areas:"resource"}
58
58
 
59
59
  /* ---- box ---- */
60
60
  .box{background:var(--panel);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
@@ -63,7 +63,6 @@
63
63
  .box[data-area=user]{grid-area:user;--accent:var(--c-user)}
64
64
  .box[data-area=permission]{grid-area:perm;--accent:var(--c-perm)}
65
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
66
  .box-head{display:flex;align-items:center;gap:9px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
68
67
  .box-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none;box-shadow:0 0 10px -2px var(--accent)}
69
68
  .box-head .title{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.02em}
@@ -122,7 +121,6 @@
122
121
  </section>
123
122
  <section class="region bottom">
124
123
  <div class="box" data-area="resource" data-type="resource"></div>
125
- <div class="box" data-area="user2" data-type="user"></div>
126
124
  </section>
127
125
  </main>
128
126
 
@@ -130,7 +128,7 @@
130
128
  // The app may be mounted under a prefix; every API call is relative to this page.
131
129
  const API = location.pathname.replace(/\/$/, "");
132
130
  const TITLES = {group:"Groups", role:"Roles", user:"Users", permission:"Permissions", resource:"Resources"};
133
- const NESTED = new Set(["group","role"]);
131
+ const NESTED = new Set(["group","role","resource"]);
134
132
 
135
133
  let GRAPH = null; // {groups,roles,users,permissions,resources,edges,authorizations_count}
136
134
  let selection = null; // {type,id}
@@ -157,8 +155,8 @@ async function load(){
157
155
  // Authorization flows user → group → role → permission → resource. From the
158
156
  // selected node we collect everything DOWNSTREAM (what it can reach) and
159
157
  // 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).
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).
162
160
  const RANK = {user:0, group:1, role:2, permission:3, resource:4};
163
161
 
164
162
  function buildDirected(){
@@ -184,9 +182,13 @@ function buildDirected(){
184
182
  // inherits ANCESTOR grants → a child group is upstream of its parent.
185
183
  // • Role grants flow to DESCENDANT roles, so holding a parent role includes
186
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.
187
188
  // (Selecting a child group therefore reaches its ancestors, never its siblings.)
188
189
  for(const g of GRAPH.groups) if(g.parent_id) dir(key("group",g.id), key("group",g.parent_id));
189
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));
190
192
  return {fwd,bwd};
191
193
  }
192
194
 
@@ -248,11 +250,15 @@ function render(){
248
250
  const name = n.name ?? "";
249
251
  // The external record occupies one slot: its label when the graph
250
252
  // stored one, otherwise the Type#id that used to be the only
251
- // rendering. Either way the other half is the tooltip.
253
+ // rendering. Either way the other half is the tooltip. A type with no
254
+ // id is the type-level (wildcard) node — every record of the type —
255
+ // and its tooltip says so, since Type#* alone reads as a typo.
256
+ const wildcard = type === "resource" && !!n.external_type && n.external_id == null;
252
257
  const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
258
+ const kind = wildcard ? "type-level grant" : "external record";
253
259
  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>`);
260
+ ? `<span class="ext" title="${wildcard ? `${ref} · ${kind}` : ref}">${escapeHtml(n.super_auth_label)}</span>`
261
+ : `<span class="ext" title="${kind}">${ref}</span>`);
256
262
  return `<div class="item ${isSel?'selected':''} ${isCF?'connect-first':''}" data-id="${n.id}" ${pad}>
257
263
  <span class="tick"></span>
258
264
  ${arrow}<span class="name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>${ext}
@@ -15,6 +15,11 @@ module SuperAuth
15
15
  # does not.
16
16
  # - "Support Lead" is the PARENT role of "Support Agent": a lead inherits
17
17
  # the agent's abilities plus refunds; an agent does not get refunds.
18
+ # - "clusters" is a CONTAINER resource holding production_cluster and
19
+ # staging_cluster. deploy is granted on the container and reaches both
20
+ # clusters through the tree; restart_server and Morgan's direct grant
21
+ # name production_cluster alone, so the tree shows a container grant
22
+ # and a leaf grant side by side.
18
23
  #
19
24
  # Special people:
20
25
  # - Riley (Auditor): read-only into BOTH Finance and Support
@@ -68,8 +73,9 @@ module SuperAuth
68
73
 
69
74
  # ===== RESOURCES (disjoint per department) =====
70
75
  source_repo = res_m.create(name: "source_repo")
71
- production_cluster = res_m.create(name: "production_cluster")
72
- staging_cluster = res_m.create(name: "staging_cluster")
76
+ clusters = res_m.create(name: "clusters") # container
77
+ production_cluster = res_m.create(name: "production_cluster", parent_id: clusters.id)
78
+ res_m.create(name: "staging_cluster", parent_id: clusters.id) # reached only through clusters
73
79
  app_database = res_m.create(name: "app_database") # Backend
74
80
  marketing_site = res_m.create(name: "marketing_site") # Frontend
75
81
  general_ledger = res_m.create(name: "general_ledger")
@@ -99,8 +105,8 @@ module SuperAuth
99
105
  edg.create(role_id: developer.id, permission_id: deploy.id)
100
106
  edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
101
107
  edg.create(permission_id: read_repo.id, resource_id: source_repo.id)
102
- edg.create(permission_id: deploy.id, resource_id: production_cluster.id)
103
- edg.create(permission_id: deploy.id, resource_id: staging_cluster.id)
108
+ # One grant on the container reaches both clusters.
109
+ edg.create(permission_id: deploy.id, resource_id: clusters.id)
104
110
  # Child-group-specific grants (Alice gets one, Bob the other)
105
111
  edg.create(group_id: backend.id, permission_id: run_migrations.id)
106
112
  edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
@@ -153,7 +159,7 @@ module SuperAuth
153
159
  def clear!
154
160
  SuperAuth::Edge.dataset.delete
155
161
  SuperAuth::Authorization.dataset.delete
156
- [SuperAuth::Group, SuperAuth::Role].each { |m| m.dataset.update(parent_id: nil) }
162
+ [SuperAuth::Group, SuperAuth::Role, SuperAuth::Resource].each { |m| m.dataset.update(parent_id: nil) }
157
163
  [SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
158
164
  m.dataset.delete
159
165
  end
@@ -3,7 +3,8 @@ require "super_auth"
3
3
 
4
4
  module SuperAuth
5
5
  # A small Rack application that edits the authorization graph: five boxes of
6
- # records, client-side traversal, node and edge CRUD, and a Recompile button.
6
+ # records (groups, roles and resources drawn as trees), client-side
7
+ # traversal, node and edge CRUD, and a Recompile button.
7
8
  # Rails-free; it needs only SuperAuth.db to be connected and the tables to
8
9
  # exist. Mount it as `run SuperAuth::Editor` (Rack) or
9
10
  # `mount SuperAuth::Editor => "/super_auth/editor"` (Rails), or run
@@ -19,7 +20,9 @@ module SuperAuth
19
20
  #
20
21
  # Edits change the graph, not runtime access: ByCurrentUser and the RLS
21
22
  # policies read the compiled super_auth_authorizations table, so the UI
22
- # shows its row count and offers POST /api/compile.
23
+ # shows its row count and offers POST /api/compile. A compile the models
24
+ # refuse (SuperAuth::Error, the wildcard guard) comes back as a 422 with
25
+ # the model's own message, like any other rejected write.
23
26
  class Editor
24
27
  TYPES = {
25
28
  "user" => :User, "group" => :Group, "role" => :Role,
@@ -29,7 +32,7 @@ module SuperAuth
29
32
  "user" => :user_id, "group" => :group_id, "role" => :role_id,
30
33
  "permission" => :permission_id, "resource" => :resource_id,
31
34
  }.freeze
32
- NESTED = %w[group role].freeze
35
+ NESTED = %w[group role resource].freeze
33
36
  # The pairs the path strategies read (see Edge.authorizations), unordered.
34
37
  # The models also accept group->resource and role->resource rows, but no
35
38
  # strategy reads them, so they would grant nothing.
@@ -69,6 +72,8 @@ module SuperAuth
69
72
  end
70
73
 
71
74
  route(method, path, env)
75
+ rescue SuperAuth::Error => e
76
+ json(422, error: e.message)
72
77
  rescue Sequel::Error
73
78
  json(422, error: "the database rejected the change")
74
79
  end
@@ -103,7 +108,7 @@ module SuperAuth
103
108
  roles: nodes(:Role, :parent_id),
104
109
  users: nodes(:User, :external_id, :external_type),
105
110
  permissions: nodes(:Permission),
106
- resources: nodes(:Resource, :external_id, :external_type, :super_auth_label),
111
+ resources: nodes(:Resource, :parent_id, :external_id, :external_type, :super_auth_label),
107
112
  edges: SuperAuth::Edge.order(:id).map { |e| edge_json(e) },
108
113
  authorizations_count: SuperAuth::Authorization.count,
109
114
  }
@@ -122,9 +127,12 @@ module SuperAuth
122
127
  # indentation, so a child has to arrive immediately after its parent or
123
128
  # it reads as nested under whatever happens to sort above it — which is
124
129
  # the one question an auditor opens this editor to answer. Sorting by the
125
- # ancestors' [name, id] pairs, outermost first, puts every child under its
126
- # own parent and leaves siblings alphabetical. Both node sets are small
127
- # enough to order in Ruby, and the client's depthOf is unaffected.
130
+ # ancestors' [name, label, id] triples, outermost first, puts every child
131
+ # under its own parent and leaves siblings alphabetical; the label only
132
+ # separates same-named siblings, which synced resources are (one "Claim"
133
+ # per record), and is absent from groups and roles. All three node sets
134
+ # are small enough to order in Ruby, and the client's depthOf is
135
+ # unaffected.
128
136
  #
129
137
  # The key is total, so the order stays defined for broken trees: a row
130
138
  # whose parent_id names a missing row sorts as a root, and a parent cycle
@@ -137,7 +145,7 @@ module SuperAuth
137
145
  node = row
138
146
  while node && !seen[node[:id]]
139
147
  seen[node[:id]] = true
140
- path.unshift([node[:name].to_s, node[:id]])
148
+ path.unshift([node[:name].to_s, node[:super_auth_label].to_s, node[:id]])
141
149
  node = by_id[node[:parent_id]]
142
150
  end
143
151
  path
@@ -158,7 +166,14 @@ module SuperAuth
158
166
  unless parent.nil?
159
167
  return json(422, error: "#{type} records cannot have a parent") unless NESTED.include?(type)
160
168
  return json(422, error: "parent_id must be an integer") unless integer_id?(parent)
161
- return json(422, error: "parent not found") unless model[parent.to_i]
169
+ parent_node = model[parent.to_i]
170
+ return json(422, error: "parent not found") unless parent_node
171
+ # "Wildcard nodes are flat": compile! refuses a tree with a type-level
172
+ # node in it, so refuse the shape at the door with the reason instead.
173
+ if type == "resource" && parent_node.external_type && parent_node.external_id.nil?
174
+ return json(422, error: "type-level (wildcard) resources are flat and cannot contain other resources; " \
175
+ "make a container (a resource with no external type) instead")
176
+ end
162
177
  attrs[:parent_id] = parent.to_i
163
178
  end
164
179
 
@@ -172,8 +187,17 @@ module SuperAuth
172
187
 
173
188
  SuperAuth.db.transaction do
174
189
  SuperAuth::Edge.where(COLUMNS[type] => record.id).delete
175
- # Children become roots: the deny-safe choice, and required before the
176
- # delete on MySQL, which checks the self-referencing key row by row.
190
+ # The node's own compiled rows go with it: runtime reads only that
191
+ # table, and a row naming a node that no longer exists would keep
192
+ # granting until the next compile. Rows compiled through it for its
193
+ # descendants stay until one runs, as after any other revocation.
194
+ SuperAuth::Authorization.where(COLUMNS[type] => record.id).delete
195
+ # Children become roots, deliberately, and the client's confirm says
196
+ # so: not the grandparent's children, whose grants would then reach
197
+ # them, and not deleted with the node, which is not what "delete this
198
+ # container" asks. A root grants nothing by itself, so it is the
199
+ # deny-safe choice. Also required before the delete on MySQL, which
200
+ # checks the self-referencing key row by row.
177
201
  model.where(parent_id: record.id).update(parent_id: nil) if NESTED.include?(type)
178
202
  model.where(id: record.id).delete
179
203
  end
@@ -24,6 +24,44 @@ module SuperAuth::Nestable
24
24
  end
25
25
  end
26
26
 
27
+ # A node may not be its own parent, nor sit under one of its own
28
+ # descendants: either closes a parent_id cycle. The pair CTEs terminate on
29
+ # one (UNION), so a cycle does not hang a compile; it does something
30
+ # quieter and worse. Every node in a cycle is an ancestor of every other,
31
+ # so a grant on any of them reaches all of their subtrees — a container
32
+ # pointed at one of its own children turned a single per-record read into
33
+ # the container's whole membership, with nothing raised anywhere. Checked
34
+ # only when parent_id changes, by walking UP from the new parent: that is
35
+ # one row per level however large the subtree, and unlike the descendant
36
+ # walk it does not stop at a type-level node
37
+ # (SuperAuth::Resource.descend_from), so a cycle through one is caught too.
38
+ # assert_acyclic! covers writes that bypass the model.
39
+ def validate
40
+ super
41
+ return if parent_id.nil? || !changed_columns.include?(:parent_id)
42
+
43
+ if parent_id == id
44
+ errors.add(:parent_id, "cannot be the node itself")
45
+ elsif !new? && model.ancestor_pairs(of: [parent_id]).where(ancestor_id: id).count > 0
46
+ errors.add(:parent_id, "is inside the node's own subtree, which would close a cycle")
47
+ end
48
+ end
49
+
50
+ # A deleted node takes its compiled rows and its edges with it, in the
51
+ # transaction that deletes the row: runtime reads only the compiled table,
52
+ # and a row naming a node that no longer exists would keep granting until
53
+ # the next compile. Children are not touched. The foreign key refuses to
54
+ # orphan them, and whether they are re-rooted or deleted is the caller's
55
+ # decision (the editor re-roots them, deliberately). Rows compiled through
56
+ # this node for its descendants stay until the next compile, as after any
57
+ # other revocation.
58
+ def before_destroy
59
+ super
60
+ column = :"#{model.singularize}_id"
61
+ SuperAuth::Authorization.where(column => id).delete
62
+ SuperAuth::Edge.where(column => id).delete
63
+ end
64
+
27
65
  module ClassMethods
28
66
  # Helper method to get the appropriate string cast type for the database
29
67
  def string_cast_type
@@ -53,24 +91,87 @@ module SuperAuth::Nestable
53
91
  # pairs on equality; matching ids inside the comma-separated path strings
54
92
  # with LIKE forced a nested loop no planner could index, and compile time
55
93
  # grew roughly cubically with the graph.
56
- def ancestor_pairs
94
+ #
95
+ # Both pair CTEs recurse with UNION rather than UNION ALL. The pair
96
+ # relation is finite (at most n² rows), so UNION stops as soon as a step
97
+ # produces nothing new, which on a parent_id cycle is the first time round;
98
+ # UNION ALL re-derives the same pairs forever and compile! never returns.
99
+ # On a valid tree no step repeats a pair, so the output is the same.
100
+ #
101
+ # `of:` (a dataset or an array of ids) restricts the anchor to those
102
+ # nodes, so only their ancestor chains are walked: one row per level.
103
+ def ancestor_pairs(of: nil)
57
104
  table = pluralize
58
105
  name = :"#{singularize}_ancestor_pairs"
59
106
  anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
107
+ anchor = anchor.where(id: of) unless of.nil?
60
108
  step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
61
109
  select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
62
- db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
110
+ db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
63
111
  end
64
112
 
65
113
  # Every node paired with itself and each of its descendants, as
66
114
  # (ancestor_id, descendant_id). Granting a role grants its whole subtree.
67
- def descendant_pairs
115
+ #
116
+ # `of:` (a dataset or an array of ids) restricts the anchor to those nodes,
117
+ # so only their subtrees are walked. Groups and roles are few and the
118
+ # whole table is cheap; resources are one row per protected record, and an
119
+ # unanchored CTE materialises every pair of the whole table once per
120
+ # strategy that joins it.
121
+ #
122
+ # The recursive step joins the parent row only when the model puts a
123
+ # condition on it (descend_from); the pair CTE itself never carries more
124
+ # than the two ids.
125
+ def descendant_pairs(of: nil)
68
126
  table = pluralize
69
127
  name = :"#{singularize}_descendant_pairs"
128
+ parent = :"#{singularize}_parent"
70
129
  anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
130
+ anchor = anchor.where(id: of) unless of.nil?
71
131
  step = db[name].join(table, parent_id: :descendant_id).
72
132
  select(Sequel[name][:ancestor_id], Sequel[table][:id])
73
- db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id])
133
+ if (condition = descend_from(parent))
134
+ step = step.join(Sequel[table].as(parent), id: Sequel[name][:descendant_id]).where(condition)
135
+ end
136
+ db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
137
+ end
138
+
139
+ # Whether descendant_pairs continues below a node: a Sequel condition on
140
+ # the parent row, addressed through the alias `parent`, or nil to descend
141
+ # from every node. Groups and roles descend from everything.
142
+ # SuperAuth::Resource stops at a type-level node, so a grant on one yields
143
+ # its own row and nothing beneath it on every path that reads the walk.
144
+ def descend_from(parent)
145
+ nil
146
+ end
147
+
148
+ # Every node some root reaches, walking parent_id downward from the rows
149
+ # that have none. On a valid forest that is the whole table; what it
150
+ # misses is exactly the nodes on or under a parent_id cycle (and, where
151
+ # no foreign key stands, a node whose parent is missing). No path
152
+ # columns, unlike trees: this runs over the whole resources table before
153
+ # every compile, and needs only the ids.
154
+ def rooted
155
+ table = pluralize
156
+ name = :"rooted_#{table}"
157
+ anchor = db[table].where(parent_id: nil).select(:id)
158
+ step = db[name].join(table, parent_id: :id).select(Sequel[table][:id])
159
+ db.from(name).with_recursive(name, anchor, step, args: [:id], union_all: false)
160
+ end
161
+
162
+ # Refuses a table with a parent_id cycle in it, naming the nodes no root
163
+ # reaches. compile! calls this for groups, roles and resources before
164
+ # touching the compiled table, because a cycle does not fail a compile:
165
+ # the walks terminate, and every node in the cycle is an ancestor of
166
+ # every other, so a grant on any of them silently reaches all of their
167
+ # subtrees. validate refuses the shape at the model; this catches it
168
+ # after a write that went around the model.
169
+ def assert_acyclic!
170
+ unreachable = dataset.exclude(id: rooted).select_order_map(:id)
171
+ return if unreachable.empty?
172
+
173
+ raise SuperAuth::Error, "#{pluralize} has a parent_id cycle: node(s) #{unreachable.join(', ')} " \
174
+ "cannot be reached from any root. Point one of them at a root, or at no parent, and recompile."
74
175
  end
75
176
 
76
177
  def cte(id = nil, direction = :desc)
@@ -0,0 +1,88 @@
1
+ # The reach map: which authorization rows admit a row of a protected table.
2
+ #
3
+ # Both layers take the same two keywords, resource_type: and parent:, and
4
+ # both ask the same question of every row: does the current user hold a
5
+ # compiled authorization that reaches it? A row is reached through its own
6
+ # id (a per-record grant, or a type-level grant on the class's own type) or
7
+ # through a column holding another record's id (a parent grant: the row's
8
+ # tenancy, read off the row itself). The reach map is that question in one
9
+ # shape, an ordered Hash from column to the types whose rows admit through
10
+ # it, with :id always the first step:
11
+ #
12
+ # SuperAuth::Reach.normalize(
13
+ # resource_type: "Claim",
14
+ # parent: { column: :organization_id, resource_type: %w[Organization::Member Organization::Admin] })
15
+ # # => { id: ["Claim"], organization_id: ["Organization::Member", "Organization::Admin"] }
16
+ #
17
+ # The RLS policy and the ByCurrentUser scope each emit one step per entry,
18
+ # and both build from this map rather than from the raw keywords so they
19
+ # cannot drift: an argument shape one layer accepted and the other rejected,
20
+ # or a column one saw and the other did not, is a row the ORM shows and the
21
+ # database hides, or the reverse, which is the dangerous direction. Every
22
+ # entry is a list because RLS must never be narrower than any tier's ORM
23
+ # scope over the same table: a table whose readers key on
24
+ # Organization::Member and whose writers on Organization::CaseWriter names
25
+ # both under the column, so the holder of one without the other is still
26
+ # admitted at the database.
27
+ #
28
+ # :id is refused as a parent column since it is the per-record step, already
29
+ # declared by resource_type:. A column declared twice is refused because the
30
+ # second entry would silently shadow the first; every type a column admits
31
+ # goes in one list.
32
+ module SuperAuth
33
+ module Reach
34
+ class << self
35
+ def normalize(resource_type:, parent: nil)
36
+ reach = { id: types(resource_type, "resource_type:") }
37
+ entries(parent).each do |entry|
38
+ unless entry.is_a?(Hash) && (entry.keys - %i[column resource_type]).empty?
39
+ raise Error, "parent: must be a Hash {column:, resource_type:} or an Array of them, got #{entry.inspect}"
40
+ end
41
+ column = column_name(entry[:column])
42
+ if reach.key?(column)
43
+ raise Error, "parent: column #{column.inspect} is declared twice; list every type it admits under one entry"
44
+ end
45
+ reach[column] = types(entry[:resource_type], "parent: #{column} resource_type:")
46
+ end
47
+ reach.freeze
48
+ end
49
+
50
+ # The column steps alone, for the layer emitting one per parent and for
51
+ # recording what a policy was built from.
52
+ def parents(reach)
53
+ reach.reject { |column, _| column == :id }
54
+ end
55
+
56
+ private
57
+
58
+ def entries(parent)
59
+ case parent
60
+ when nil then []
61
+ when Hash then [parent]
62
+ when Array then parent
63
+ else raise Error, "parent: must be a Hash {column:, resource_type:} or an Array of them, got #{parent.inspect}"
64
+ end
65
+ end
66
+
67
+ def column_name(column)
68
+ name = column.to_s if column.is_a?(Symbol) || column.is_a?(String)
69
+ if name.nil? || name.empty?
70
+ raise Error, "parent: column: must be a Symbol or String naming a column, got #{column.inspect}"
71
+ end
72
+ if name == "id"
73
+ raise Error, "parent: column: :id is the per-record step, which resource_type: already declares; a parent is a column holding another record's id"
74
+ end
75
+ name.to_sym
76
+ end
77
+
78
+ # Frozen copies rather than freezing the caller's strings in place.
79
+ def types(value, label)
80
+ list = value.is_a?(Array) ? value : [value]
81
+ unless !list.empty? && list.all? { |type| type.is_a?(String) && !type.empty? }
82
+ raise Error, "#{label} must be a String or a non-empty Array of Strings, got #{value.inspect}"
83
+ end
84
+ list.uniq.map { |type| -type }.freeze
85
+ end
86
+ end
87
+ end
88
+ end