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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +286 -22
- data/USAGE.md +41 -16
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate/1_users.rb +1 -1
- data/db/migrate/5_resources.rb +1 -1
- data/db/migrate/7_authorization.rb +2 -2
- data/db/migrate/8_add_indexes_to_edges.rb +7 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
- data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
- data/exe/super_auth-editor +9 -0
- data/lib/generators/super_auth/install/templates/README +15 -10
- data/lib/generators/super_auth/install/templates/super_auth.rb +16 -5
- data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +15 -0
- data/lib/super_auth/active_record/by_current_user.rb +25 -5
- data/lib/super_auth/active_record/resource.rb +41 -0
- data/lib/super_auth/active_record/user.rb +3 -1
- data/lib/super_auth/authorization.rb +12 -0
- data/lib/super_auth/edge.rb +65 -100
- data/lib/super_auth/editor/cli.rb +91 -0
- data/lib/super_auth/editor/index.html +423 -0
- data/lib/super_auth/editor/seed.rb +170 -0
- data/lib/super_auth/editor.rb +273 -0
- data/lib/super_auth/nestable.rb +41 -3
- data/lib/super_auth/railtie.rb +0 -2
- data/lib/super_auth/rls.rb +256 -0
- data/lib/super_auth/user.rb +3 -1
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +85 -0
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +15 -9
- data/VISUALIZATION.md +0 -58
- data/app/controllers/super_auth/graph_controller.rb +0 -654
- data/app/views/super_auth/graph/index.html.erb +0 -1408
- data/super_auth.gemspec +0 -35
- 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
|
data/lib/super_auth/nestable.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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),
|
data/lib/super_auth/railtie.rb
CHANGED
|
@@ -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
|
data/lib/super_auth/user.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/super_auth/version.rb
CHANGED