super_auth 0.3.3 → 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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +286 -22
  6. data/USAGE.md +41 -16
  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/1_users.rb +1 -1
  10. data/db/migrate/5_resources.rb +1 -1
  11. data/db/migrate/7_authorization.rb +2 -2
  12. data/db/migrate/8_add_indexes_to_edges.rb +7 -0
  13. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
  14. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
  16. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  17. data/exe/super_auth-editor +9 -0
  18. data/lib/generators/super_auth/install/templates/README +15 -10
  19. data/lib/generators/super_auth/install/templates/super_auth.rb +16 -5
  20. data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
  21. data/lib/generators/super_auth/rls/templates/migration.rb.erb +15 -0
  22. data/lib/super_auth/active_record/by_current_user.rb +25 -5
  23. data/lib/super_auth/active_record/resource.rb +41 -0
  24. data/lib/super_auth/active_record/user.rb +3 -1
  25. data/lib/super_auth/authorization.rb +12 -0
  26. data/lib/super_auth/edge.rb +65 -100
  27. data/lib/super_auth/editor/cli.rb +91 -0
  28. data/lib/super_auth/editor/index.html +423 -0
  29. data/lib/super_auth/editor/seed.rb +170 -0
  30. data/lib/super_auth/editor.rb +273 -0
  31. data/lib/super_auth/nestable.rb +41 -3
  32. data/lib/super_auth/railtie.rb +0 -2
  33. data/lib/super_auth/rls.rb +256 -0
  34. data/lib/super_auth/user.rb +3 -1
  35. data/lib/super_auth/version.rb +1 -1
  36. data/lib/super_auth.rb +85 -0
  37. data/lib/tasks/super_auth_tasks.rake +28 -0
  38. metadata +15 -9
  39. data/VISUALIZATION.md +0 -58
  40. data/app/controllers/super_auth/graph_controller.rb +0 -654
  41. data/app/views/super_auth/graph/index.html.erb +0 -1408
  42. data/super_auth.gemspec +0 -35
  43. data/visualization.html +0 -747
@@ -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
@@ -35,6 +35,44 @@ module SuperAuth::Nestable
35
35
  end
36
36
  end
37
37
 
38
+ # Cast type for the anchor row of the path CTEs. MySQL types a recursive
39
+ # CTE's columns from the anchor SELECT alone, so a bare CAST(id AS CHAR)
40
+ # makes the path column varchar(11) and every deeper level overflows it
41
+ # ("Data too long for column"). :text is unbounded elsewhere.
42
+ def path_cast_type
43
+ case SuperAuth.db.database_type
44
+ when :mysql, :mysql2
45
+ "char(4000)"
46
+ else
47
+ :text
48
+ end
49
+ end
50
+
51
+ # Every node paired with itself and each of its ancestors, as
52
+ # (descendant_id, ancestor_id). The path strategies join these integer
53
+ # pairs on equality; matching ids inside the comma-separated path strings
54
+ # with LIKE forced a nested loop no planner could index, and compile time
55
+ # grew roughly cubically with the graph.
56
+ def ancestor_pairs
57
+ table = pluralize
58
+ name = :"#{singularize}_ancestor_pairs"
59
+ anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
60
+ step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
61
+ select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
62
+ db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
63
+ end
64
+
65
+ # Every node paired with itself and each of its descendants, as
66
+ # (ancestor_id, descendant_id). Granting a role grants its whole subtree.
67
+ def descendant_pairs
68
+ table = pluralize
69
+ name = :"#{singularize}_descendant_pairs"
70
+ anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
71
+ step = db[name].join(table, parent_id: :descendant_id).
72
+ select(Sequel[name][:ancestor_id], Sequel[table][:id])
73
+ db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id])
74
+ end
75
+
38
76
  def cte(id = nil, direction = :desc)
39
77
  model = self
40
78
  cte_name = model.cte_name
@@ -69,8 +107,8 @@ module SuperAuth::Nestable
69
107
  def with_descending_paths(base_ds, recursive_ds, cte_name)
70
108
  [
71
109
  base_ds.select_append(
72
- Sequel[table_name][:id].cast(string_cast_type).as(base_path)
73
- ).select_append(Sequel[table_name][:name].as(base_name_path)),
110
+ Sequel[table_name][:id].cast(path_cast_type).as(base_path)
111
+ ).select_append(Sequel[table_name][:name].cast(path_cast_type).as(base_name_path)),
74
112
 
75
113
  recursive_ds.select_append(
76
114
  Sequel.function(:concat,
@@ -90,7 +128,7 @@ module SuperAuth::Nestable
90
128
 
91
129
  def with_ascending_paths(base_ds, recursive_ds, cte_name)
92
130
  [
93
- base_ds.select_append(Sequel[table_name][:id].cast(string_cast_type).as(base_path)).select_append(Sequel[table_name][:name].as(base_name_path)),
131
+ base_ds.select_append(Sequel[table_name][:id].cast(path_cast_type).as(base_path)).select_append(Sequel[table_name][:name].cast(path_cast_type).as(base_name_path)),
94
132
  recursive_ds.select_append(
95
133
  Sequel.function(:concat,
96
134
  Sequel[table_name][:id].cast(string_cast_type),
@@ -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'
@@ -0,0 +1,256 @@
1
+ # Postgres Row-Level Security enforcement.
2
+ #
3
+ # ByCurrentUser filters queries at the ORM layer; RLS enforces the same rule
4
+ # inside Postgres, so raw SQL, `unscoped`, and non-Ruby clients are subject
5
+ # to it too — enforcing apps don't load this gem at all. Identity is
6
+ # asserted per transaction by two SQL functions installed by `enable`:
7
+ #
8
+ # super_auth_become(user_external_id, user_external_type, user_id)
9
+ # asserts a user's identity. Executable by PUBLIC.
10
+ # super_auth_system()
11
+ # asserts system context, which bypasses every policy. EXECUTE is
12
+ # revoked from PUBLIC; `grant_system(role)` hands it to the roles that
13
+ # may bypass.
14
+ #
15
+ # `enable` also grants every role SELECT on the gem's own tables, which the
16
+ # policies and the user models read, so a runtime role needs privileges on
17
+ # the application's tables and nothing else.
18
+ #
19
+ # Both set transaction-local identity settings plus a stamp of the current
20
+ # transaction id, and every policy requires a stamp from the current
21
+ # transaction. Identity therefore cannot outlive its transaction or leak
22
+ # across pooled connections — a query without a fresh assertion sees no
23
+ # rows. Both raise if the calling role is a superuser or has BYPASSRLS:
24
+ # Postgres exempts those roles from every policy, so an identity assertion
25
+ # from one would protect nothing while looking like it does.
26
+ module SuperAuth
27
+ module RLS
28
+ POLICY = "super_auth".freeze
29
+
30
+ class << self
31
+ # Enable RLS on an app table with a policy mirroring ByCurrentUser:
32
+ # type-level authorization rows (resource_external_id IS NULL) act as a
33
+ # wildcard, per-record rows match on id, and system context bypasses.
34
+ #
35
+ # One deliberate divergence: INSERTs are also gated. The policy is
36
+ # FOR ALL with no WITH CHECK, so Postgres reuses its USING expression
37
+ # as the implicit WITH CHECK for new rows. Creating rows therefore
38
+ # requires a type-level authorization for the resource type, or
39
+ # system context.
40
+ def enable(table, resource_type:, db: SuperAuth.db)
41
+ postgres!(db)
42
+ create_functions(db)
43
+ grant_runtime_reads(db)
44
+ t = db.literal(Sequel.identifier(table.to_s))
45
+ db.run "ALTER TABLE #{t} ENABLE ROW LEVEL SECURITY"
46
+ # FORCE: apply the policy even when the app connects as the table owner
47
+ db.run "ALTER TABLE #{t} FORCE ROW LEVEL SECURITY"
48
+ db.run "DROP POLICY IF EXISTS #{POLICY} ON #{t}"
49
+ db.run <<~SQL
50
+ CREATE POLICY #{POLICY} ON #{t}
51
+ USING (
52
+ current_setting('super_auth.xid', true) = pg_current_xact_id()::text
53
+ AND (
54
+ COALESCE(current_setting('super_auth.system', true), '') = 'true'
55
+ OR EXISTS (
56
+ SELECT 1 FROM super_auth_authorizations a
57
+ WHERE a.resource_external_type = #{db.literal(resource_type.to_s)}
58
+ AND (a.resource_external_id IS NULL OR a.resource_external_id = #{t}.id)
59
+ AND (
60
+ a.user_id::text = NULLIF(current_setting('super_auth.user_id', true), '')
61
+ OR (
62
+ a.user_external_id::text = NULLIF(current_setting('super_auth.user_external_id', true), '')
63
+ AND a.user_external_type = NULLIF(current_setting('super_auth.user_external_type', true), '')
64
+ )
65
+ )
66
+ )
67
+ )
68
+ )
69
+ SQL
70
+ end
71
+
72
+ # Drops the table's policy; the shared functions are left in place
73
+ # (other tables may still be protected, and they are harmless on their
74
+ # own).
75
+ def disable(table, db: SuperAuth.db)
76
+ postgres!(db)
77
+ t = db.literal(Sequel.identifier(table.to_s))
78
+ db.run "DROP POLICY IF EXISTS #{POLICY} ON #{t}"
79
+ db.run "ALTER TABLE #{t} NO FORCE ROW LEVEL SECURITY"
80
+ db.run "ALTER TABLE #{t} DISABLE ROW LEVEL SECURITY"
81
+ end
82
+
83
+ # Run the block with `user`'s identity asserted for one transaction —
84
+ # the Ruby face of the SQL contract
85
+ # (BEGIN; SELECT super_auth_become(...); queries; COMMIT). Sequel and
86
+ # ActiveRecord queries inside the block share the transaction's
87
+ # connection, so the policies see the identity; it dies with the
88
+ # transaction. A user whose `system?` is true asserts system context
89
+ # through super_auth_system() instead, which the connection's role
90
+ # must have been granted EXECUTE on.
91
+ #
92
+ # Inside an enclosing transaction (the caller's, or an outer `as`) it
93
+ # joins that transaction instead of opening one, and it puts the
94
+ # enclosing identity back when the block ends, however it ends: the
95
+ # innermost assertion wins inside the block and nothing else afterwards.
96
+ # This touches only the database settings; SuperAuth.as is the call that
97
+ # also sets SuperAuth.current_user.
98
+ #
99
+ # Transaction options pass through to Sequel's transaction. One matters
100
+ # for a wrapper that exists only to carry an identity:
101
+ # auto_savepoint: true every nested transaction becomes a savepoint
102
+ # (the ActiveRecord bridge turns this into
103
+ # joinable: false), so a save inside the block
104
+ # commits on its own and its after_commit hooks
105
+ # fire then, not at the end of the block.
106
+ # Whether a write survives the block raising is the caller's policy, not
107
+ # this wrapper's: rescue inside the block to keep it, or let the
108
+ # exception out to roll it back.
109
+ def as(user, db: SuperAuth.db, **transaction_options)
110
+ postgres!(db)
111
+ # Outside a transaction the settings die at COMMIT and there is nothing
112
+ # to restore; inside one, the enclosing identity must survive the block.
113
+ enclosing = db.in_transaction? ? identity(db) : nil
114
+ db.transaction(**transaction_options) do
115
+ assert(user, db: db)
116
+ begin
117
+ yield
118
+ ensure
119
+ restore(enclosing, db) if enclosing
120
+ end
121
+ end
122
+ end
123
+
124
+ # Assert `user`'s identity in the transaction the caller already holds,
125
+ # without opening one: the SELECT super_auth_become(...) half of the
126
+ # contract, or super_auth_system() for a user whose `system?` is true.
127
+ # For re-asserting mid-transaction, and for code that manages its own
128
+ # transaction and only needs the identity in it. Outside a transaction
129
+ # the settings die with the statement, so it protects nothing there.
130
+ def assert(user, db: SuperAuth.db)
131
+ postgres!(db)
132
+ if user.respond_to?(:system?) && user.system?
133
+ db.get(Sequel.function(:super_auth_system))
134
+ else
135
+ db.get(Sequel.function(:super_auth_become, *become_args(user)))
136
+ end
137
+ end
138
+
139
+ # Whether `enable` has run on this database: both identity functions
140
+ # exist with their current signatures. One query per call, so a hot
141
+ # path memoises it. False on a non-Postgres database, where RLS cannot
142
+ # be installed.
143
+ def installed?(db: SuperAuth.db)
144
+ return false unless db.database_type == :postgres
145
+ db.get(Sequel.lit("to_regprocedure('super_auth_become(text,text,text)') IS NOT NULL AND to_regprocedure('super_auth_system()') IS NOT NULL"))
146
+ end
147
+
148
+ # Allow `role` to assert system context: SuperAuth.as with a user whose
149
+ # system? is true, or SELECT super_auth_system() directly. enable revokes
150
+ # this from PUBLIC; grant it to the roles that run migrations, seeds and
151
+ # admin jobs, and to nothing else.
152
+ def grant_system(role, db: SuperAuth.db)
153
+ postgres!(db)
154
+ db.run "GRANT EXECUTE ON FUNCTION super_auth_system() TO #{db.literal(Sequel.identifier(role.to_s))}"
155
+ end
156
+
157
+ private
158
+
159
+ # What any runtime role needs on the gem's own tables: the policies read
160
+ # super_auth_authorizations as the querying role, and the user models'
161
+ # system? reads super_auth_users. Granting PUBLIC makes enable the only
162
+ # setup step; a deployment that wants these tables private can REVOKE
163
+ # from PUBLIC and grant per role.
164
+ def grant_runtime_reads(db)
165
+ db.run "GRANT SELECT ON super_auth_authorizations, super_auth_users TO PUBLIC"
166
+ end
167
+
168
+ # Refuses an identity assertion from a role Postgres exempts from row
169
+ # security: the policies would apply to nobody while everything looked
170
+ # enforced. Checks the effective role, so a superuser session that has
171
+ # SET ROLE to an application role passes.
172
+ SUPERUSER_GUARD = <<~SQL.freeze
173
+ IF (SELECT rolsuper OR rolbypassrls FROM pg_roles WHERE rolname = current_user) THEN
174
+ RAISE EXCEPTION 'super_auth: role % is a superuser or has BYPASSRLS, so row-level security does not apply to it and asserting an identity would protect nothing. Connect as a regular role.', current_user
175
+ USING ERRCODE = 'invalid_authorization_specification';
176
+ END IF;
177
+ SQL
178
+
179
+ # Two shared functions per database; clients assert identity by calling
180
+ # one of them inside their transaction. CREATE OR REPLACE keeps enable
181
+ # idempotent. The pre-0.5 four-argument super_auth_become carried the
182
+ # system bypass as its last parameter; a REPLACE with a different
183
+ # signature would leave that overload in place, so it is dropped.
184
+ def create_functions(db)
185
+ db.run "DROP FUNCTION IF EXISTS super_auth_become(text, text, text, boolean)"
186
+ db.run <<~SQL
187
+ CREATE OR REPLACE FUNCTION super_auth_become(
188
+ user_external_id text DEFAULT NULL,
189
+ user_external_type text DEFAULT NULL,
190
+ user_id text DEFAULT NULL
191
+ ) RETURNS void LANGUAGE plpgsql AS $$
192
+ BEGIN
193
+ #{SUPERUSER_GUARD}
194
+ PERFORM set_config('super_auth.user_id', COALESCE(user_id, ''), true),
195
+ set_config('super_auth.user_external_id', COALESCE(user_external_id, ''), true),
196
+ set_config('super_auth.user_external_type', COALESCE(user_external_type, ''), true),
197
+ set_config('super_auth.system', '', true),
198
+ set_config('super_auth.xid', pg_current_xact_id()::text, true);
199
+ END
200
+ $$;
201
+ SQL
202
+ db.run <<~SQL
203
+ CREATE OR REPLACE FUNCTION super_auth_system() RETURNS void LANGUAGE plpgsql AS $$
204
+ BEGIN
205
+ #{SUPERUSER_GUARD}
206
+ PERFORM set_config('super_auth.user_id', '', true),
207
+ set_config('super_auth.user_external_id', '', true),
208
+ set_config('super_auth.user_external_type', '', true),
209
+ set_config('super_auth.system', 'true', true),
210
+ set_config('super_auth.xid', pg_current_xact_id()::text, true);
211
+ END
212
+ $$;
213
+ SQL
214
+ # Bypass is opt-in per role: GRANT EXECUTE ON FUNCTION super_auth_system() TO <role>.
215
+ db.run "REVOKE EXECUTE ON FUNCTION super_auth_system() FROM PUBLIC"
216
+ end
217
+
218
+ # super_auth_become's three arguments for `user`: a SuperAuth user
219
+ # record goes by user_id, any other object by id and class name, nil by
220
+ # nothing, an identity no authorization matches.
221
+ def become_args(user)
222
+ if user.nil?
223
+ [nil, nil, nil]
224
+ elsif SuperAuth.internal_user?(user)
225
+ [nil, nil, user.id.to_s]
226
+ else
227
+ [user.id.to_s, user.class.name, nil]
228
+ end
229
+ end
230
+
231
+ # The five transaction-local settings the policies read, in one order.
232
+ SETTINGS = %w[
233
+ super_auth.user_id super_auth.user_external_id super_auth.user_external_type
234
+ super_auth.system super_auth.xid
235
+ ].freeze
236
+
237
+ def identity(db)
238
+ db.dataset.get(SETTINGS.each_with_index.map { |name, i| Sequel.function(:current_setting, name, true).as(:"s#{i}") })
239
+ end
240
+
241
+ # Writes the settings back directly: the values were read from this same
242
+ # transaction, so the stamp is still the current one, and no role is
243
+ # granted anything it could not already set.
244
+ def restore(values, db)
245
+ db.dataset.get(SETTINGS.zip(values).each_with_index.map { |(name, value), i| Sequel.function(:set_config, name, value.to_s, true).as(:"s#{i}") })
246
+ rescue Sequel::DatabaseError
247
+ # The block aborted the transaction; its rollback discards the settings.
248
+ end
249
+
250
+ def postgres!(db)
251
+ return if db.database_type == :postgres
252
+ raise SuperAuth::Error, "SuperAuth::RLS requires Postgres (got #{db.database_type})"
253
+ end
254
+ end
255
+ end
256
+ end
@@ -2,7 +2,9 @@ class SuperAuth::User < Sequel::Model(:super_auth_users)
2
2
  one_to_many :edges
3
3
  one_to_many :resources
4
4
 
5
- def system? = self.class.system == self
5
+ # A read: runtime roles only get SELECT on this table. `.system` creates
6
+ # the row when missing and belongs to migrations, seeds and consoles.
7
+ def system? = self.class.first(name: "system") == self
6
8
  def self.system = find_or_create(name: "system")
7
9
 
8
10
  dataset_module do
@@ -1,3 +1,3 @@
1
1
  module SuperAuth
2
- VERSION = "0.3.3"
2
+ VERSION = "0.7.0"
3
3
  end