super_auth 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,170 @@
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
+ #
19
+ # Special people:
20
+ # - Riley (Auditor): read-only into BOTH Finance and Support
21
+ # - Morgan (Admin): direct user->resource access across departments
22
+ # - Nina (New Hire): no access at all
23
+ #
24
+ # Destructive: run! replaces the whole graph, including the compiled
25
+ # authorizations. Only ever runs on request (super_auth-editor --seed).
26
+ module Seed
27
+ module_function
28
+
29
+ # Returns the row counts per table.
30
+ def run!
31
+ SuperAuth.db.transaction do
32
+ clear!
33
+
34
+ grp = SuperAuth::Group
35
+ rol = SuperAuth::Role
36
+ usr = SuperAuth::User
37
+ perm_m = SuperAuth::Permission
38
+ res_m = SuperAuth::Resource
39
+ edg = SuperAuth::Edge
40
+
41
+ # ===== GROUPS (Engineering is a parent of Backend + Frontend) =====
42
+ engineering = grp.create(name: "Engineering")
43
+ backend = grp.create(name: "Backend", parent_id: engineering.id)
44
+ frontend = grp.create(name: "Frontend", parent_id: engineering.id)
45
+ finance = grp.create(name: "Finance")
46
+ support = grp.create(name: "Customer Support")
47
+
48
+ # ===== ROLES (Support Lead is the parent of Support Agent) =====
49
+ developer = rol.create(name: "Developer")
50
+ sre = rol.create(name: "SRE")
51
+ accountant = rol.create(name: "Accountant")
52
+ support_lead = rol.create(name: "Support Lead")
53
+ support_agent = rol.create(name: "Support Agent", parent_id: support_lead.id)
54
+
55
+ # ===== PERMISSIONS (disjoint per department) =====
56
+ merge_code = perm_m.create(name: "merge_code")
57
+ read_repo = perm_m.create(name: "read_repo")
58
+ deploy = perm_m.create(name: "deploy")
59
+ run_migrations = perm_m.create(name: "run_migrations") # Backend-only
60
+ publish_site = perm_m.create(name: "publish_site") # Frontend-only
61
+ restart_server = perm_m.create(name: "restart_server") # SRE-only
62
+ view_ledger = perm_m.create(name: "view_ledger")
63
+ issue_invoice = perm_m.create(name: "issue_invoice")
64
+ run_payroll = perm_m.create(name: "run_payroll")
65
+ view_ticket = perm_m.create(name: "view_ticket")
66
+ close_ticket = perm_m.create(name: "close_ticket")
67
+ issue_refund = perm_m.create(name: "issue_refund") # Support Lead-only
68
+
69
+ # ===== RESOURCES (disjoint per department) =====
70
+ 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")
73
+ app_database = res_m.create(name: "app_database") # Backend
74
+ marketing_site = res_m.create(name: "marketing_site") # Frontend
75
+ general_ledger = res_m.create(name: "general_ledger")
76
+ invoices = res_m.create(name: "invoices")
77
+ support_tickets = res_m.create(name: "support_tickets")
78
+ customer_accounts = res_m.create(name: "customer_accounts")
79
+
80
+ # ===== USERS =====
81
+ alice = usr.create(name: "Alice") # Backend dev
82
+ bob = usr.create(name: "Bob") # Frontend dev
83
+ sam = usr.create(name: "Sam") # SRE
84
+ carol = usr.create(name: "Carol") # Accountant
85
+ dave = usr.create(name: "Dave") # Accountant
86
+ erin = usr.create(name: "Erin") # Support agent
87
+ frank = usr.create(name: "Frank") # Support lead
88
+ riley = usr.create(name: "Riley") # Auditor (cross-department, read-only)
89
+ morgan = usr.create(name: "Morgan") # Admin (direct resource access)
90
+ usr.create(name: "Nina") # New hire, no access yet
91
+
92
+ # ===== ENGINEERING =====
93
+ edg.create(user_id: alice.id, group_id: backend.id)
94
+ edg.create(user_id: bob.id, group_id: frontend.id)
95
+ # Shared Developer role on the PARENT group: both Alice and Bob inherit it
96
+ edg.create(group_id: engineering.id, role_id: developer.id)
97
+ edg.create(role_id: developer.id, permission_id: merge_code.id)
98
+ edg.create(role_id: developer.id, permission_id: read_repo.id)
99
+ edg.create(role_id: developer.id, permission_id: deploy.id)
100
+ edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
101
+ 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)
104
+ # Child-group-specific grants (Alice gets one, Bob the other)
105
+ edg.create(group_id: backend.id, permission_id: run_migrations.id)
106
+ edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
107
+ edg.create(group_id: frontend.id, permission_id: publish_site.id)
108
+ edg.create(permission_id: publish_site.id, resource_id: marketing_site.id)
109
+ # Sam is an SRE via a direct role assignment
110
+ edg.create(user_id: sam.id, role_id: sre.id)
111
+ edg.create(role_id: sre.id, permission_id: restart_server.id)
112
+ edg.create(role_id: sre.id, permission_id: deploy.id)
113
+ edg.create(permission_id: restart_server.id, resource_id: production_cluster.id)
114
+
115
+ # ===== FINANCE =====
116
+ edg.create(user_id: carol.id, group_id: finance.id)
117
+ edg.create(user_id: dave.id, group_id: finance.id)
118
+ edg.create(group_id: finance.id, role_id: accountant.id)
119
+ edg.create(role_id: accountant.id, permission_id: view_ledger.id)
120
+ edg.create(role_id: accountant.id, permission_id: issue_invoice.id)
121
+ edg.create(role_id: accountant.id, permission_id: run_payroll.id)
122
+ edg.create(permission_id: view_ledger.id, resource_id: general_ledger.id)
123
+ edg.create(permission_id: issue_invoice.id, resource_id: invoices.id)
124
+ edg.create(permission_id: run_payroll.id, resource_id: general_ledger.id)
125
+
126
+ # ===== SUPPORT (Lead inherits Agent's abilities via the role hierarchy) =====
127
+ edg.create(user_id: erin.id, group_id: support.id)
128
+ edg.create(user_id: frank.id, group_id: support.id)
129
+ edg.create(user_id: frank.id, role_id: support_lead.id) # Frank is a lead
130
+ edg.create(group_id: support.id, role_id: support_agent.id) # everyone is at least an agent
131
+ edg.create(role_id: support_agent.id, permission_id: view_ticket.id)
132
+ edg.create(role_id: support_agent.id, permission_id: close_ticket.id)
133
+ edg.create(role_id: support_lead.id, permission_id: issue_refund.id)
134
+ edg.create(permission_id: view_ticket.id, resource_id: support_tickets.id)
135
+ edg.create(permission_id: close_ticket.id, resource_id: support_tickets.id)
136
+ edg.create(permission_id: issue_refund.id, resource_id: customer_accounts.id)
137
+
138
+ # ===== CROSS-CUTTERS =====
139
+ # Riley audits both the ledger and tickets (direct permission grants).
140
+ edg.create(user_id: riley.id, permission_id: view_ledger.id)
141
+ edg.create(user_id: riley.id, permission_id: view_ticket.id)
142
+ # Morgan has direct resource access across departments (simplest path).
143
+ edg.create(user_id: morgan.id, resource_id: production_cluster.id)
144
+ edg.create(user_id: morgan.id, resource_id: general_ledger.id)
145
+ edg.create(user_id: morgan.id, resource_id: support_tickets.id)
146
+
147
+ counts
148
+ end
149
+ end
150
+
151
+ # Empties the graph and the compiled table. Parents are detached first:
152
+ # MySQL checks the self-referencing key row by row.
153
+ def clear!
154
+ SuperAuth::Edge.dataset.delete
155
+ SuperAuth::Authorization.dataset.delete
156
+ [SuperAuth::Group, SuperAuth::Role].each { |m| m.dataset.update(parent_id: nil) }
157
+ [SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
158
+ m.dataset.delete
159
+ end
160
+ end
161
+
162
+ def counts
163
+ {
164
+ groups: SuperAuth::Group.count, roles: SuperAuth::Role.count, users: SuperAuth::User.count,
165
+ permissions: SuperAuth::Permission.count, resources: SuperAuth::Resource.count, edges: SuperAuth::Edge.count,
166
+ }
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,273 @@
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, client-side traversal, node and edge CRUD, and a Recompile button.
7
+ # Rails-free; it needs only SuperAuth.db to be connected and the tables to
8
+ # exist. Mount it as `run SuperAuth::Editor` (Rack) or
9
+ # `mount SuperAuth::Editor => "/super_auth/editor"` (Rails), or run
10
+ # `super_auth-editor`, which serves it on loopback.
11
+ #
12
+ # It has no authentication of its own. Anyone who can reach it can rewrite
13
+ # the graph, so the host must put its own authentication in front of the
14
+ # mount. Two stdlib-only guards remain: writes must be application/json (a
15
+ # cross-origin browser cannot send that without a CORS preflight, which is
16
+ # never answered) and cross-site fetches are refused; `hosts:` additionally
17
+ # rejects any other Host header, the DNS-rebinding defence the executable
18
+ # turns on for loopback.
19
+ #
20
+ # Edits change the graph, not runtime access: ByCurrentUser and the RLS
21
+ # policies read the compiled super_auth_authorizations table, so the UI
22
+ # shows its row count and offers POST /api/compile.
23
+ class Editor
24
+ TYPES = {
25
+ "user" => :User, "group" => :Group, "role" => :Role,
26
+ "permission" => :Permission, "resource" => :Resource,
27
+ }.freeze
28
+ COLUMNS = {
29
+ "user" => :user_id, "group" => :group_id, "role" => :role_id,
30
+ "permission" => :permission_id, "resource" => :resource_id,
31
+ }.freeze
32
+ NESTED = %w[group role].freeze
33
+ # The pairs the path strategies read (see Edge.authorizations), unordered.
34
+ # The models also accept group->resource and role->resource rows, but no
35
+ # strategy reads them, so they would grant nothing.
36
+ ALLOWED_PAIRS = [
37
+ %w[user group], %w[user role], %w[user permission], %w[user resource],
38
+ %w[group role], %w[group permission], %w[role permission], %w[permission resource],
39
+ ].map(&:sort).freeze
40
+ EMPTY_EDGE = { user_id: nil, group_id: nil, role_id: nil, permission_id: nil, resource_id: nil }.freeze
41
+ INDEX_HTML = File.read(File.join(__dir__, "editor", "index.html")).freeze
42
+ MAX_BODY = 64 * 1024
43
+ ID = /\A\d+\z/
44
+ NAME_MAX = 255
45
+
46
+ def self.call(env)
47
+ (@default ||= new).call(env)
48
+ end
49
+
50
+ # hosts: host names (port ignored) this app answers to; nil disables the check.
51
+ def initialize(hosts: nil)
52
+ @hosts = hosts && hosts.map { |h| h.to_s.downcase }
53
+ end
54
+
55
+ def call(env)
56
+ return forbidden("host not allowed") if @hosts && !@hosts.include?(host_of(env))
57
+
58
+ begin
59
+ SuperAuth.load unless defined?(SuperAuth::User)
60
+ rescue Sequel::DatabaseError
61
+ return json(503, error: "super_auth tables not found; run the migrations (super_auth-editor --migrate, or your application's)")
62
+ end
63
+
64
+ method = env["REQUEST_METHOD"]
65
+ path = env["PATH_INFO"].to_s
66
+ path = "/" if path.empty?
67
+ if %w[POST DELETE].include?(method) && env["HTTP_SEC_FETCH_SITE"] == "cross-site"
68
+ return forbidden("cross-site requests are not accepted")
69
+ end
70
+
71
+ route(method, path, env)
72
+ rescue Sequel::Error
73
+ json(422, error: "the database rejected the change")
74
+ end
75
+
76
+ private
77
+
78
+ def route(method, path, env)
79
+ if method == "GET" && path == "/"
80
+ html
81
+ elsif method == "GET" && path == "/api/graph"
82
+ json(200, graph)
83
+ elsif method == "POST" && path == "/api/compile"
84
+ json(200, count: SuperAuth::Authorization.compile!)
85
+ elsif method == "POST" && path == "/api/edges"
86
+ with_body(env) { |body| create_edge(body) }
87
+ elsif method == "DELETE" && (m = path.match(%r{\A/api/edges/([^/]+)\z}))
88
+ delete_edge(m[1])
89
+ elsif method == "POST" && (m = path.match(%r{\A/api/nodes/([^/]+)\z}))
90
+ with_body(env) { |body| create_node(m[1], body) }
91
+ elsif method == "DELETE" && (m = path.match(%r{\A/api/nodes/([^/]+)/([^/]+)\z}))
92
+ delete_node(m[1], m[2])
93
+ else
94
+ json(404, error: "not found")
95
+ end
96
+ end
97
+
98
+ # ---- reads ----
99
+
100
+ def graph
101
+ {
102
+ groups: nodes(:Group, :parent_id),
103
+ roles: nodes(:Role, :parent_id),
104
+ users: nodes(:User, :external_id, :external_type),
105
+ permissions: nodes(:Permission),
106
+ resources: nodes(:Resource, :external_id, :external_type, :super_auth_label),
107
+ edges: SuperAuth::Edge.order(:id).map { |e| edge_json(e) },
108
+ authorizations_count: SuperAuth::Authorization.count,
109
+ }
110
+ end
111
+
112
+ def nodes(model, *extra)
113
+ rows = SuperAuth.const_get(model).order(:name, :id).map do |n|
114
+ row = { id: n.id, name: n.name }
115
+ extra.each { |column| row[column] = n[column] }
116
+ row
117
+ end
118
+ extra.include?(:parent_id) ? tree_order(rows) : rows
119
+ end
120
+
121
+ # The nested types render as a flat list that fakes the tree with
122
+ # indentation, so a child has to arrive immediately after its parent or
123
+ # it reads as nested under whatever happens to sort above it — which is
124
+ # 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.
128
+ #
129
+ # The key is total, so the order stays defined for broken trees: a row
130
+ # whose parent_id names a missing row sorts as a root, and a parent cycle
131
+ # stops at the first repeated id rather than walking forever.
132
+ def tree_order(rows)
133
+ by_id = rows.each_with_object({}) { |row, index| index[row[:id]] = row }
134
+ rows.sort_by do |row|
135
+ path = []
136
+ seen = {}
137
+ node = row
138
+ while node && !seen[node[:id]]
139
+ seen[node[:id]] = true
140
+ path.unshift([node[:name].to_s, node[:id]])
141
+ node = by_id[node[:parent_id]]
142
+ end
143
+ path
144
+ end
145
+ end
146
+
147
+ # ---- writes ----
148
+
149
+ def create_node(type, body)
150
+ model = model_for(type) or return json(404, error: "unknown node type")
151
+ name = body["name"].to_s.strip
152
+ return json(422, error: "name is required") if name.empty?
153
+ return json(422, error: "name is too long (#{NAME_MAX} characters max)") if name.length > NAME_MAX
154
+ return json(422, error: "the name \"system\" is reserved") if type == "user" && name == "system"
155
+
156
+ attrs = { name: name }
157
+ parent = body["parent_id"]
158
+ unless parent.nil?
159
+ return json(422, error: "#{type} records cannot have a parent") unless NESTED.include?(type)
160
+ 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]
162
+ attrs[:parent_id] = parent.to_i
163
+ end
164
+
165
+ json(201, node_json(model.create(attrs)))
166
+ end
167
+
168
+ def delete_node(type, id)
169
+ model = model_for(type) or return json(404, error: "unknown node type")
170
+ record = integer_id?(id) && model[id.to_i]
171
+ return json(404, error: "not found") unless record
172
+
173
+ SuperAuth.db.transaction do
174
+ 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.
177
+ model.where(parent_id: record.id).update(parent_id: nil) if NESTED.include?(type)
178
+ model.where(id: record.id).delete
179
+ end
180
+ json(200, ok: true)
181
+ end
182
+
183
+ def create_edge(body)
184
+ a_type = body["a_type"].to_s
185
+ b_type = body["b_type"].to_s
186
+ return json(422, error: "unknown node type") unless COLUMNS[a_type] && COLUMNS[b_type]
187
+ return json(422, error: "an edge links two different node types") if a_type == b_type
188
+ unless ALLOWED_PAIRS.include?([a_type, b_type].sort)
189
+ return json(422, error: "no path strategy reads #{a_type} -> #{b_type} edges; it would grant nothing")
190
+ end
191
+ return json(422, error: "ids must be integers") unless integer_id?(body["a_id"]) && integer_id?(body["b_id"])
192
+
193
+ a_id = body["a_id"].to_i
194
+ b_id = body["b_id"].to_i
195
+ return json(404, error: "#{a_type} #{a_id} not found") unless model_for(a_type)[a_id]
196
+ return json(404, error: "#{b_type} #{b_id} not found") unless model_for(b_type)[b_id]
197
+
198
+ # The full five-column hash, so a row created here always has exactly two ids.
199
+ attrs = EMPTY_EDGE.merge(COLUMNS[a_type] => a_id, COLUMNS[b_type] => b_id)
200
+ if (edge = SuperAuth::Edge.where(attrs).first)
201
+ json(200, edge_json(edge))
202
+ else
203
+ json(201, edge_json(SuperAuth::Edge.create(attrs)))
204
+ end
205
+ end
206
+
207
+ def delete_edge(id)
208
+ edge = integer_id?(id) && SuperAuth::Edge[id.to_i]
209
+ return json(404, error: "not found") unless edge
210
+
211
+ edge.delete
212
+ json(200, ok: true)
213
+ end
214
+
215
+ # ---- helpers ----
216
+
217
+ def model_for(type)
218
+ TYPES[type] && SuperAuth.const_get(TYPES[type])
219
+ end
220
+
221
+ def integer_id?(value)
222
+ (value.is_a?(Integer) && value >= 0) || (value.is_a?(String) && value.match?(ID))
223
+ end
224
+
225
+ def node_json(record)
226
+ {
227
+ id: record.id,
228
+ name: record.name,
229
+ parent_id: record.respond_to?(:parent_id) ? record.parent_id : nil,
230
+ external_id: record.respond_to?(:external_id) ? record.external_id : nil,
231
+ external_type: record.respond_to?(:external_type) ? record.external_type : nil,
232
+ }
233
+ end
234
+
235
+ def edge_json(edge)
236
+ { id: edge.id, user_id: edge.user_id, group_id: edge.group_id, role_id: edge.role_id,
237
+ permission_id: edge.permission_id, resource_id: edge.resource_id }
238
+ end
239
+
240
+ def with_body(env)
241
+ media_type = env["CONTENT_TYPE"].to_s.split(";").first.to_s.strip.downcase
242
+ return json(415, error: "send application/json") unless media_type == "application/json"
243
+
244
+ input = env["rack.input"]
245
+ raw = input ? input.read(MAX_BODY + 1).to_s : ""
246
+ return json(413, error: "body too large") if raw.bytesize > MAX_BODY
247
+
248
+ body = raw.empty? ? nil : JSON.parse(raw)
249
+ return json(400, error: "body must be a JSON object") unless body.is_a?(Hash)
250
+
251
+ yield body
252
+ rescue JSON::ParserError
253
+ json(400, error: "body must be a JSON object")
254
+ end
255
+
256
+ def host_of(env)
257
+ host = env["HTTP_HOST"].to_s.downcase
258
+ host.start_with?("[") ? host[/\A\[[^\]]*\]/].to_s : host.split(":").first.to_s
259
+ end
260
+
261
+ def json(status, payload)
262
+ [status, { "content-type" => "application/json; charset=utf-8", "cache-control" => "no-store" }, [JSON.generate(payload)]]
263
+ end
264
+
265
+ def html
266
+ [200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store", "x-frame-options" => "DENY" }, [INDEX_HTML]]
267
+ end
268
+
269
+ def forbidden(message)
270
+ json(403, error: message)
271
+ end
272
+ end
273
+ end
@@ -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'