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
@@ -0,0 +1,176 @@
1
+ require "super_auth"
2
+
3
+ module SuperAuth
4
+ class Editor
5
+ # Sample graph for the editor. "Acme Cloud": three departments that are
6
+ # deliberately DISJOINT. Engineering, Finance, and Support each have their
7
+ # own users, roles, permissions, and resources with no shared nodes, so
8
+ # traversal is obvious: click anyone in Engineering and only Engineering
9
+ # lights up.
10
+ #
11
+ # The hierarchy matches how grants inherit:
12
+ # - The shared "Developer" role is attached to the Engineering PARENT
13
+ # group, so every engineer (Backend + Frontend) inherits it, while
14
+ # Backend and Frontend each also hold a child-group grant the other
15
+ # does not.
16
+ # - "Support Lead" is the PARENT role of "Support Agent": a lead inherits
17
+ # the agent's abilities plus refunds; an agent does not get refunds.
18
+ # - "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.
23
+ #
24
+ # Special people:
25
+ # - Riley (Auditor): read-only into BOTH Finance and Support
26
+ # - Morgan (Admin): direct user->resource access across departments
27
+ # - Nina (New Hire): no access at all
28
+ #
29
+ # Destructive: run! replaces the whole graph, including the compiled
30
+ # authorizations. Only ever runs on request (super_auth-editor --seed).
31
+ module Seed
32
+ module_function
33
+
34
+ # Returns the row counts per table.
35
+ def run!
36
+ SuperAuth.db.transaction do
37
+ clear!
38
+
39
+ grp = SuperAuth::Group
40
+ rol = SuperAuth::Role
41
+ usr = SuperAuth::User
42
+ perm_m = SuperAuth::Permission
43
+ res_m = SuperAuth::Resource
44
+ edg = SuperAuth::Edge
45
+
46
+ # ===== GROUPS (Engineering is a parent of Backend + Frontend) =====
47
+ engineering = grp.create(name: "Engineering")
48
+ backend = grp.create(name: "Backend", parent_id: engineering.id)
49
+ frontend = grp.create(name: "Frontend", parent_id: engineering.id)
50
+ finance = grp.create(name: "Finance")
51
+ support = grp.create(name: "Customer Support")
52
+
53
+ # ===== ROLES (Support Lead is the parent of Support Agent) =====
54
+ developer = rol.create(name: "Developer")
55
+ sre = rol.create(name: "SRE")
56
+ accountant = rol.create(name: "Accountant")
57
+ support_lead = rol.create(name: "Support Lead")
58
+ support_agent = rol.create(name: "Support Agent", parent_id: support_lead.id)
59
+
60
+ # ===== PERMISSIONS (disjoint per department) =====
61
+ merge_code = perm_m.create(name: "merge_code")
62
+ read_repo = perm_m.create(name: "read_repo")
63
+ deploy = perm_m.create(name: "deploy")
64
+ run_migrations = perm_m.create(name: "run_migrations") # Backend-only
65
+ publish_site = perm_m.create(name: "publish_site") # Frontend-only
66
+ restart_server = perm_m.create(name: "restart_server") # SRE-only
67
+ view_ledger = perm_m.create(name: "view_ledger")
68
+ issue_invoice = perm_m.create(name: "issue_invoice")
69
+ run_payroll = perm_m.create(name: "run_payroll")
70
+ view_ticket = perm_m.create(name: "view_ticket")
71
+ close_ticket = perm_m.create(name: "close_ticket")
72
+ issue_refund = perm_m.create(name: "issue_refund") # Support Lead-only
73
+
74
+ # ===== RESOURCES (disjoint per department) =====
75
+ source_repo = res_m.create(name: "source_repo")
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
79
+ app_database = res_m.create(name: "app_database") # Backend
80
+ marketing_site = res_m.create(name: "marketing_site") # Frontend
81
+ general_ledger = res_m.create(name: "general_ledger")
82
+ invoices = res_m.create(name: "invoices")
83
+ support_tickets = res_m.create(name: "support_tickets")
84
+ customer_accounts = res_m.create(name: "customer_accounts")
85
+
86
+ # ===== USERS =====
87
+ alice = usr.create(name: "Alice") # Backend dev
88
+ bob = usr.create(name: "Bob") # Frontend dev
89
+ sam = usr.create(name: "Sam") # SRE
90
+ carol = usr.create(name: "Carol") # Accountant
91
+ dave = usr.create(name: "Dave") # Accountant
92
+ erin = usr.create(name: "Erin") # Support agent
93
+ frank = usr.create(name: "Frank") # Support lead
94
+ riley = usr.create(name: "Riley") # Auditor (cross-department, read-only)
95
+ morgan = usr.create(name: "Morgan") # Admin (direct resource access)
96
+ usr.create(name: "Nina") # New hire, no access yet
97
+
98
+ # ===== ENGINEERING =====
99
+ edg.create(user_id: alice.id, group_id: backend.id)
100
+ edg.create(user_id: bob.id, group_id: frontend.id)
101
+ # Shared Developer role on the PARENT group: both Alice and Bob inherit it
102
+ edg.create(group_id: engineering.id, role_id: developer.id)
103
+ edg.create(role_id: developer.id, permission_id: merge_code.id)
104
+ edg.create(role_id: developer.id, permission_id: read_repo.id)
105
+ edg.create(role_id: developer.id, permission_id: deploy.id)
106
+ edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
107
+ edg.create(permission_id: read_repo.id, resource_id: source_repo.id)
108
+ # One grant on the container reaches both clusters.
109
+ edg.create(permission_id: deploy.id, resource_id: clusters.id)
110
+ # Child-group-specific grants (Alice gets one, Bob the other)
111
+ edg.create(group_id: backend.id, permission_id: run_migrations.id)
112
+ edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
113
+ edg.create(group_id: frontend.id, permission_id: publish_site.id)
114
+ edg.create(permission_id: publish_site.id, resource_id: marketing_site.id)
115
+ # Sam is an SRE via a direct role assignment
116
+ edg.create(user_id: sam.id, role_id: sre.id)
117
+ edg.create(role_id: sre.id, permission_id: restart_server.id)
118
+ edg.create(role_id: sre.id, permission_id: deploy.id)
119
+ edg.create(permission_id: restart_server.id, resource_id: production_cluster.id)
120
+
121
+ # ===== FINANCE =====
122
+ edg.create(user_id: carol.id, group_id: finance.id)
123
+ edg.create(user_id: dave.id, group_id: finance.id)
124
+ edg.create(group_id: finance.id, role_id: accountant.id)
125
+ edg.create(role_id: accountant.id, permission_id: view_ledger.id)
126
+ edg.create(role_id: accountant.id, permission_id: issue_invoice.id)
127
+ edg.create(role_id: accountant.id, permission_id: run_payroll.id)
128
+ edg.create(permission_id: view_ledger.id, resource_id: general_ledger.id)
129
+ edg.create(permission_id: issue_invoice.id, resource_id: invoices.id)
130
+ edg.create(permission_id: run_payroll.id, resource_id: general_ledger.id)
131
+
132
+ # ===== SUPPORT (Lead inherits Agent's abilities via the role hierarchy) =====
133
+ edg.create(user_id: erin.id, group_id: support.id)
134
+ edg.create(user_id: frank.id, group_id: support.id)
135
+ edg.create(user_id: frank.id, role_id: support_lead.id) # Frank is a lead
136
+ edg.create(group_id: support.id, role_id: support_agent.id) # everyone is at least an agent
137
+ edg.create(role_id: support_agent.id, permission_id: view_ticket.id)
138
+ edg.create(role_id: support_agent.id, permission_id: close_ticket.id)
139
+ edg.create(role_id: support_lead.id, permission_id: issue_refund.id)
140
+ edg.create(permission_id: view_ticket.id, resource_id: support_tickets.id)
141
+ edg.create(permission_id: close_ticket.id, resource_id: support_tickets.id)
142
+ edg.create(permission_id: issue_refund.id, resource_id: customer_accounts.id)
143
+
144
+ # ===== CROSS-CUTTERS =====
145
+ # Riley audits both the ledger and tickets (direct permission grants).
146
+ edg.create(user_id: riley.id, permission_id: view_ledger.id)
147
+ edg.create(user_id: riley.id, permission_id: view_ticket.id)
148
+ # Morgan has direct resource access across departments (simplest path).
149
+ edg.create(user_id: morgan.id, resource_id: production_cluster.id)
150
+ edg.create(user_id: morgan.id, resource_id: general_ledger.id)
151
+ edg.create(user_id: morgan.id, resource_id: support_tickets.id)
152
+
153
+ counts
154
+ end
155
+ end
156
+
157
+ # Empties the graph and the compiled table. Parents are detached first:
158
+ # MySQL checks the self-referencing key row by row.
159
+ def clear!
160
+ SuperAuth::Edge.dataset.delete
161
+ SuperAuth::Authorization.dataset.delete
162
+ [SuperAuth::Group, SuperAuth::Role, SuperAuth::Resource].each { |m| m.dataset.update(parent_id: nil) }
163
+ [SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
164
+ m.dataset.delete
165
+ end
166
+ end
167
+
168
+ def counts
169
+ {
170
+ groups: SuperAuth::Group.count, roles: SuperAuth::Role.count, users: SuperAuth::User.count,
171
+ permissions: SuperAuth::Permission.count, resources: SuperAuth::Resource.count, edges: SuperAuth::Edge.count,
172
+ }
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,288 @@
1
+ require "json"
2
+ require "super_auth"
3
+
4
+ module SuperAuth
5
+ # A small Rack application that edits the authorization graph: five boxes of
6
+ # records (groups, roles and resources drawn as trees), client-side
7
+ # traversal, node and edge CRUD, and a Recompile button.
8
+ # Rails-free; it needs only SuperAuth.db to be connected and the tables to
9
+ # exist. Mount it as `run SuperAuth::Editor` (Rack) or
10
+ # `mount SuperAuth::Editor => "/super_auth/editor"` (Rails), or run
11
+ # `super_auth-editor`, which serves it on loopback.
12
+ #
13
+ # It has no authentication of its own. Anyone who can reach it can rewrite
14
+ # the graph, so the host must put its own authentication in front of the
15
+ # mount. Two stdlib-only guards remain: writes must be application/json (a
16
+ # cross-origin browser cannot send that without a CORS preflight, which is
17
+ # never answered) and cross-site fetches are refused; `hosts:` additionally
18
+ # rejects any other Host header, the DNS-rebinding defence the executable
19
+ # turns on for loopback.
20
+ #
21
+ # Edits change the graph, not runtime access: ByCurrentUser and the RLS
22
+ # policies read the compiled super_auth_authorizations table, so the UI
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.
26
+ class Editor
27
+ TYPES = {
28
+ "user" => :User, "group" => :Group, "role" => :Role,
29
+ "permission" => :Permission, "resource" => :Resource,
30
+ }.freeze
31
+ COLUMNS = {
32
+ "user" => :user_id, "group" => :group_id, "role" => :role_id,
33
+ "permission" => :permission_id, "resource" => :resource_id,
34
+ }.freeze
35
+ NESTED = %w[group role resource].freeze
36
+ # The pairs the path strategies read (see Edge.authorizations), unordered.
37
+ # The models also accept group->resource and role->resource rows, but no
38
+ # strategy reads them, so they would grant nothing.
39
+ ALLOWED_PAIRS = [
40
+ %w[user group], %w[user role], %w[user permission], %w[user resource],
41
+ %w[group role], %w[group permission], %w[role permission], %w[permission resource],
42
+ ].map(&:sort).freeze
43
+ EMPTY_EDGE = { user_id: nil, group_id: nil, role_id: nil, permission_id: nil, resource_id: nil }.freeze
44
+ INDEX_HTML = File.read(File.join(__dir__, "editor", "index.html")).freeze
45
+ MAX_BODY = 64 * 1024
46
+ ID = /\A\d+\z/
47
+ NAME_MAX = 255
48
+
49
+ def self.call(env)
50
+ (@default ||= new).call(env)
51
+ end
52
+
53
+ # hosts: host names (port ignored) this app answers to; nil disables the check.
54
+ def initialize(hosts: nil)
55
+ @hosts = hosts && hosts.map { |h| h.to_s.downcase }
56
+ end
57
+
58
+ def call(env)
59
+ return forbidden("host not allowed") if @hosts && !@hosts.include?(host_of(env))
60
+
61
+ begin
62
+ SuperAuth.load unless defined?(SuperAuth::User)
63
+ rescue Sequel::DatabaseError
64
+ return json(503, error: "super_auth tables not found; run the migrations (super_auth-editor --migrate, or your application's)")
65
+ end
66
+
67
+ method = env["REQUEST_METHOD"]
68
+ path = env["PATH_INFO"].to_s
69
+ path = "/" if path.empty?
70
+ if %w[POST DELETE].include?(method) && env["HTTP_SEC_FETCH_SITE"] == "cross-site"
71
+ return forbidden("cross-site requests are not accepted")
72
+ end
73
+
74
+ route(method, path, env)
75
+ rescue SuperAuth::Error => e
76
+ json(422, error: e.message)
77
+ rescue Sequel::Error
78
+ json(422, error: "the database rejected the change")
79
+ end
80
+
81
+ private
82
+
83
+ def route(method, path, env)
84
+ if method == "GET" && path == "/"
85
+ html
86
+ elsif method == "GET" && path == "/api/graph"
87
+ json(200, graph)
88
+ elsif method == "POST" && path == "/api/compile"
89
+ json(200, count: SuperAuth::Authorization.compile!)
90
+ elsif method == "POST" && path == "/api/edges"
91
+ with_body(env) { |body| create_edge(body) }
92
+ elsif method == "DELETE" && (m = path.match(%r{\A/api/edges/([^/]+)\z}))
93
+ delete_edge(m[1])
94
+ elsif method == "POST" && (m = path.match(%r{\A/api/nodes/([^/]+)\z}))
95
+ with_body(env) { |body| create_node(m[1], body) }
96
+ elsif method == "DELETE" && (m = path.match(%r{\A/api/nodes/([^/]+)/([^/]+)\z}))
97
+ delete_node(m[1], m[2])
98
+ else
99
+ json(404, error: "not found")
100
+ end
101
+ end
102
+
103
+ # ---- reads ----
104
+
105
+ def graph
106
+ {
107
+ groups: nodes(:Group, :parent_id),
108
+ roles: nodes(:Role, :parent_id),
109
+ users: nodes(:User, :external_id, :external_type),
110
+ permissions: nodes(:Permission),
111
+ resources: nodes(:Resource, :parent_id, :external_id, :external_type, :super_auth_label),
112
+ edges: SuperAuth::Edge.order(:id).map { |e| edge_json(e) },
113
+ authorizations_count: SuperAuth::Authorization.count,
114
+ }
115
+ end
116
+
117
+ def nodes(model, *extra)
118
+ rows = SuperAuth.const_get(model).order(:name, :id).map do |n|
119
+ row = { id: n.id, name: n.name }
120
+ extra.each { |column| row[column] = n[column] }
121
+ row
122
+ end
123
+ extra.include?(:parent_id) ? tree_order(rows) : rows
124
+ end
125
+
126
+ # The nested types render as a flat list that fakes the tree with
127
+ # indentation, so a child has to arrive immediately after its parent or
128
+ # it reads as nested under whatever happens to sort above it — which is
129
+ # the one question an auditor opens this editor to answer. Sorting by the
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.
136
+ #
137
+ # The key is total, so the order stays defined for broken trees: a row
138
+ # whose parent_id names a missing row sorts as a root, and a parent cycle
139
+ # stops at the first repeated id rather than walking forever.
140
+ def tree_order(rows)
141
+ by_id = rows.each_with_object({}) { |row, index| index[row[:id]] = row }
142
+ rows.sort_by do |row|
143
+ path = []
144
+ seen = {}
145
+ node = row
146
+ while node && !seen[node[:id]]
147
+ seen[node[:id]] = true
148
+ path.unshift([node[:name].to_s, node[:super_auth_label].to_s, node[:id]])
149
+ node = by_id[node[:parent_id]]
150
+ end
151
+ path
152
+ end
153
+ end
154
+
155
+ # ---- writes ----
156
+
157
+ def create_node(type, body)
158
+ model = model_for(type) or return json(404, error: "unknown node type")
159
+ name = body["name"].to_s.strip
160
+ return json(422, error: "name is required") if name.empty?
161
+ return json(422, error: "name is too long (#{NAME_MAX} characters max)") if name.length > NAME_MAX
162
+ return json(422, error: "the name \"system\" is reserved") if type == "user" && name == "system"
163
+
164
+ attrs = { name: name }
165
+ parent = body["parent_id"]
166
+ unless parent.nil?
167
+ return json(422, error: "#{type} records cannot have a parent") unless NESTED.include?(type)
168
+ return json(422, error: "parent_id must be an integer") unless integer_id?(parent)
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 deprecated and cannot contain other resources; " \
175
+ "make a container (a resource with no external type) instead")
176
+ end
177
+ attrs[:parent_id] = parent.to_i
178
+ end
179
+
180
+ json(201, node_json(model.create(attrs)))
181
+ end
182
+
183
+ def delete_node(type, id)
184
+ model = model_for(type) or return json(404, error: "unknown node type")
185
+ record = integer_id?(id) && model[id.to_i]
186
+ return json(404, error: "not found") unless record
187
+
188
+ SuperAuth.db.transaction do
189
+ SuperAuth::Edge.where(COLUMNS[type] => record.id).delete
190
+ # Children become roots: the deny-safe choice, and required before the
191
+ # delete on MySQL, which checks the self-referencing key row by row.
192
+ model.where(parent_id: record.id).update(parent_id: nil) if NESTED.include?(type)
193
+ model.where(id: record.id).delete
194
+ end
195
+ json(200, ok: true)
196
+ end
197
+
198
+ def create_edge(body)
199
+ a_type = body["a_type"].to_s
200
+ b_type = body["b_type"].to_s
201
+ return json(422, error: "unknown node type") unless COLUMNS[a_type] && COLUMNS[b_type]
202
+ return json(422, error: "an edge links two different node types") if a_type == b_type
203
+ unless ALLOWED_PAIRS.include?([a_type, b_type].sort)
204
+ return json(422, error: "no path strategy reads #{a_type} -> #{b_type} edges; it would grant nothing")
205
+ end
206
+ return json(422, error: "ids must be integers") unless integer_id?(body["a_id"]) && integer_id?(body["b_id"])
207
+
208
+ a_id = body["a_id"].to_i
209
+ b_id = body["b_id"].to_i
210
+ return json(404, error: "#{a_type} #{a_id} not found") unless model_for(a_type)[a_id]
211
+ return json(404, error: "#{b_type} #{b_id} not found") unless model_for(b_type)[b_id]
212
+
213
+ # The full five-column hash, so a row created here always has exactly two ids.
214
+ attrs = EMPTY_EDGE.merge(COLUMNS[a_type] => a_id, COLUMNS[b_type] => b_id)
215
+ if (edge = SuperAuth::Edge.where(attrs).first)
216
+ json(200, edge_json(edge))
217
+ else
218
+ json(201, edge_json(SuperAuth::Edge.create(attrs)))
219
+ end
220
+ end
221
+
222
+ def delete_edge(id)
223
+ edge = integer_id?(id) && SuperAuth::Edge[id.to_i]
224
+ return json(404, error: "not found") unless edge
225
+
226
+ edge.delete
227
+ json(200, ok: true)
228
+ end
229
+
230
+ # ---- helpers ----
231
+
232
+ def model_for(type)
233
+ TYPES[type] && SuperAuth.const_get(TYPES[type])
234
+ end
235
+
236
+ def integer_id?(value)
237
+ (value.is_a?(Integer) && value >= 0) || (value.is_a?(String) && value.match?(ID))
238
+ end
239
+
240
+ def node_json(record)
241
+ {
242
+ id: record.id,
243
+ name: record.name,
244
+ parent_id: record.respond_to?(:parent_id) ? record.parent_id : nil,
245
+ external_id: record.respond_to?(:external_id) ? record.external_id : nil,
246
+ external_type: record.respond_to?(:external_type) ? record.external_type : nil,
247
+ }
248
+ end
249
+
250
+ def edge_json(edge)
251
+ { id: edge.id, user_id: edge.user_id, group_id: edge.group_id, role_id: edge.role_id,
252
+ permission_id: edge.permission_id, resource_id: edge.resource_id }
253
+ end
254
+
255
+ def with_body(env)
256
+ media_type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
257
+ return json(415, error: "send application/json") unless media_type == "application/json"
258
+
259
+ input = env["rack.input"]
260
+ raw = input ? input.read(MAX_BODY + 1).to_s : ""
261
+ return json(413, error: "body too large") if raw.bytesize > MAX_BODY
262
+
263
+ body = raw.empty? ? nil : JSON.parse(raw)
264
+ return json(400, error: "body must be a JSON object") unless body.is_a?(Hash)
265
+
266
+ yield body
267
+ rescue JSON::ParserError
268
+ json(400, error: "body must be a JSON object")
269
+ end
270
+
271
+ def host_of(env)
272
+ host = env["HTTP_HOST"].to_s.downcase
273
+ host.start_with?("[") ? host[/\A\[[^\]]*\]/].to_s : host.split(":").first.to_s
274
+ end
275
+
276
+ def json(status, payload)
277
+ [status, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [JSON.generate(payload)]]
278
+ end
279
+
280
+ def html
281
+ [200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store", "x-frame-options" => "DENY" }, [INDEX_HTML]]
282
+ end
283
+
284
+ def forbidden(message)
285
+ json(403, error: message)
286
+ end
287
+ end
288
+ end
@@ -53,24 +53,37 @@ module SuperAuth::Nestable
53
53
  # pairs on equality; matching ids inside the comma-separated path strings
54
54
  # with LIKE forced a nested loop no planner could index, and compile time
55
55
  # grew roughly cubically with the graph.
56
+ #
57
+ # Both pair CTEs recurse with UNION rather than UNION ALL. The pair
58
+ # relation is finite (at most n² rows), so UNION stops as soon as a step
59
+ # produces nothing new, which on a parent_id cycle is the first time round;
60
+ # UNION ALL re-derives the same pairs forever and compile! never returns.
61
+ # On a valid tree no step repeats a pair, so the output is the same.
56
62
  def ancestor_pairs
57
63
  table = pluralize
58
64
  name = :"#{singularize}_ancestor_pairs"
59
65
  anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
60
66
  step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
61
67
  select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
62
- db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
68
+ db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
63
69
  end
64
70
 
65
71
  # Every node paired with itself and each of its descendants, as
66
72
  # (ancestor_id, descendant_id). Granting a role grants its whole subtree.
67
- def descendant_pairs
73
+ #
74
+ # `of:` (a dataset or an array of ids) restricts the anchor to those nodes,
75
+ # so only their subtrees are walked. Groups and roles are few and the
76
+ # whole table is cheap; resources are one row per protected record, and an
77
+ # unanchored CTE materialises every pair of the whole table once per
78
+ # strategy that joins it.
79
+ def descendant_pairs(of: nil)
68
80
  table = pluralize
69
81
  name = :"#{singularize}_descendant_pairs"
70
82
  anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
83
+ anchor = anchor.where(id: of) unless of.nil?
71
84
  step = db[name].join(table, parent_id: :descendant_id).
72
85
  select(Sequel[name][:ancestor_id], Sequel[table][:id])
73
- db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id])
86
+ db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
74
87
  end
75
88
 
76
89
  def cte(id = nil, direction = :desc)
@@ -3,8 +3,6 @@ module SuperAuth
3
3
  class Engine < Rails::Engine
4
4
  isolate_namespace SuperAuth
5
5
 
6
- config.paths.add 'app/controllers', eager_load: true
7
-
8
6
  # Use ActiveRecord migrations when in a Rails environment
9
7
  if defined?(ActiveRecord)
10
8
  config.paths['db/migrate'] = 'db/migrate_activerecord'
@@ -18,6 +16,15 @@ module SuperAuth
18
16
  load "tasks/super_auth_tasks.rake"
19
17
  end
20
18
 
19
+ # Rails 7.1+ keeps one deprecator per library and applies
20
+ # config.active_support.deprecation / report_deprecations to each of
21
+ # them in the active_support.deprecation_behavior initializer, which
22
+ # runs after load_environment_config — so registration has to come
23
+ # before that, as Rails' own railties do.
24
+ initializer "super_auth.deprecator", before: :load_environment_config do |app|
25
+ app.deprecators[:super_auth] = SuperAuth.deprecator if app.respond_to?(:deprecators)
26
+ end
27
+
21
28
  initializer "super_auth.initialize" do
22
29
  if defined?(ActiveRecord) && defined?(ActiveRecord::Base)
23
30
  SuperAuth.db
@@ -1,2 +1,59 @@
1
1
  class SuperAuth::Resource < Sequel::Model(:super_auth_resources)
2
+ # Resources nest like groups and roles: a grant on a node reaches the node
3
+ # and every node under it (SuperAuth::Edge.join_resource_subtree). No
4
+ # unrestrict_primary_key, unlike those two: it exists for the ActiveRecord
5
+ # twins' descendants_dataset, which builds a Sequel model with an id, and
6
+ # the ActiveRecord Resource deliberately has none — nothing at runtime
7
+ # walks a resource tree.
8
+ include SuperAuth::Nestable
9
+
10
+ class << self
11
+ # A node with an external_type and no external_id is a type-level
12
+ # (wildcard) node: at runtime it means every record of that type, present
13
+ # and future (the ByCurrentUser type_level branch, the policy's
14
+ # `resource_external_id IS NULL OR` clause). A node with neither is a
15
+ # container. Wildcards are deprecated (see warn_deprecated_wildcards) but
16
+ # still the only way to authorize INSERT under row-level security, so they
17
+ # stay; what they may not do is join the tree.
18
+ def wildcards
19
+ exclude(external_type: nil).where(external_id: nil)
20
+ end
21
+
22
+ # "Wildcard nodes are flat." compile! calls this before touching the
23
+ # compiled table, so a refused compile leaves the previous rows in place.
24
+ # A wildcard with a parent would compile to a (type, NULL) row reachable
25
+ # through every ancestor's grants — one edge to a container silently
26
+ # granting every record of a type — and a wildcard with children would
27
+ # make the children unreachable except through a grant that already covers
28
+ # them; neither is a shape anyone means. One query: the wildcards that
29
+ # have a parent, or that some node names as its parent.
30
+ def assert_compilable!
31
+ parents = dataset.exclude(parent_id: nil).select(:parent_id)
32
+ nested = wildcards.where(Sequel.|(Sequel.~(parent_id: nil), { id: parents })).select_order_map(:id)
33
+ return if nested.empty?
34
+
35
+ raise SuperAuth::Error, "Wildcard resource nodes must be flat, but wildcard node(s) #{nested.join(', ')} " \
36
+ "have a parent or children. A resource node with an external_type and no external_id is a wildcard " \
37
+ "for every record of that type, not a container: nested in the tree it would compile to a row that " \
38
+ "reaches every record of its type through the tree. Move each to the root with no children, or give " \
39
+ "it an external_id."
40
+ end
41
+
42
+ # One warning per compile, naming what exists, through SuperAuth.deprecator
43
+ # so a Rails host's deprecation config (notify, raise, silence) applies.
44
+ # A no-op when there are none, which is the common case.
45
+ def warn_deprecated_wildcards
46
+ rows = wildcards.order(:id).select_map([:id, :name])
47
+ return if rows.empty?
48
+
49
+ listed = rows.first(10).map { |id, name| "#{name} (#{id})" }
50
+ listed << "..." if rows.size > 10
51
+ SuperAuth.deprecator.warn(
52
+ "#{rows.size} type-level (wildcard) resource node#{'s' if rows.size > 1} " \
53
+ "(external_type set, external_id NULL): #{listed.join(', ')}. Wildcard nodes are deprecated. " \
54
+ "They still work, and they remain the only way to authorize INSERT under row-level security; " \
55
+ "the successor is a grant on a parent record. See the CHANGELOG."
56
+ )
57
+ end
58
+ end
2
59
  end