super_auth 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/Gemfile.lock +1 -1
- data/README.md +440 -42
- data/USAGE.md +127 -21
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate/12_add_resource_indexes.rb +116 -0
- data/db/migrate/13_add_resource_tree_guard.rb +28 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
- data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
- data/db/seeds/sample_data.rb +1 -0
- data/lib/generators/super_auth/install/templates/README +6 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +14 -3
- data/lib/super_auth/active_record/by_current_user.rb +136 -29
- data/lib/super_auth/active_record/group.rb +3 -0
- data/lib/super_auth/active_record/nested.rb +43 -0
- data/lib/super_auth/active_record/resource.rb +28 -4
- data/lib/super_auth/active_record/role.rb +3 -0
- data/lib/super_auth/active_record.rb +30 -2
- data/lib/super_auth/authorization.rb +63 -10
- data/lib/super_auth/edge.rb +73 -18
- data/lib/super_auth/editor/index.html +16 -10
- data/lib/super_auth/editor/seed.rb +11 -5
- data/lib/super_auth/editor.rb +35 -11
- data/lib/super_auth/nestable.rb +105 -4
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +71 -0
- data/lib/super_auth/rls.rb +576 -44
- data/lib/super_auth/tree_guard.rb +115 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +55 -2
- metadata +10 -1
data/lib/super_auth/rls.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Postgres Row-Level Security enforcement.
|
|
2
2
|
#
|
|
3
|
-
# ByCurrentUser filters queries at the ORM layer; RLS enforces the same
|
|
4
|
-
# inside Postgres, so raw SQL, `unscoped`, and non-Ruby clients are
|
|
5
|
-
# to it too — enforcing apps don't load this gem at all. Identity is
|
|
3
|
+
# ByCurrentUser filters queries at the ORM layer; RLS enforces the same
|
|
4
|
+
# reach inside Postgres, so raw SQL, `unscoped`, and non-Ruby clients are
|
|
5
|
+
# subject to it too — enforcing apps don't load this gem at all. Identity is
|
|
6
6
|
# asserted per transaction by two SQL functions installed by `enable`:
|
|
7
7
|
#
|
|
8
8
|
# super_auth_become(user_external_id, user_external_type, user_id)
|
|
@@ -23,61 +23,258 @@
|
|
|
23
23
|
# rows. Both raise if the calling role is a superuser or has BYPASSRLS:
|
|
24
24
|
# Postgres exempts those roles from every policy, so an identity assertion
|
|
25
25
|
# from one would protect nothing while looking like it does.
|
|
26
|
+
#
|
|
27
|
+
# What a policy decides is reach (SuperAuth::Reach): a row is admitted when
|
|
28
|
+
# the asserted identity holds a compiled authorization that reaches it,
|
|
29
|
+
# through the row's own id — a per-record row, or a type-level row on the
|
|
30
|
+
# table's own type — or through a column holding the id of a record the row
|
|
31
|
+
# belongs to, its tenancy read off the row itself. Authorization protection
|
|
32
|
+
# is always the language client's ORM plus the super_auth database together:
|
|
33
|
+
# the policy is the tenancy boundary, and capability — which of the tenants
|
|
34
|
+
# admitted may write — is client code. The policy is FOR ALL with no WITH
|
|
35
|
+
# CHECK, so Postgres reuses USING for new and updated rows, and it gates no
|
|
36
|
+
# verb: a holder of a read tier passes it for UPDATE and DELETE at the
|
|
37
|
+
# database, and an unfiltered DELETE by such a holder removes every row of
|
|
38
|
+
# their tenancy without an error. That is the design, not a gap to close
|
|
39
|
+
# here; RLS is the portable base that lets other languages and future apps
|
|
40
|
+
# start from a real boundary, not the whole of authorization.
|
|
41
|
+
require "json"
|
|
42
|
+
|
|
26
43
|
module SuperAuth
|
|
27
44
|
module RLS
|
|
28
45
|
POLICY = "super_auth".freeze
|
|
46
|
+
# Every name enable has ever given a policy. Postgres ORs permissive
|
|
47
|
+
# policies, so one left behind under a previous name would keep admitting
|
|
48
|
+
# rows beside the current one; enable drops every name here before it
|
|
49
|
+
# creates POLICY, and the list only grows.
|
|
50
|
+
POLICY_NAMES = %w[super_auth].freeze
|
|
51
|
+
# Bumped when the policy template changes and at no other time. Recorded
|
|
52
|
+
# in each policy's comment so current? and stale can tell a table built
|
|
53
|
+
# by an earlier enable from one built by this one; installed? does not
|
|
54
|
+
# read it and keeps meaning only that the identity functions exist.
|
|
55
|
+
POLICY_VERSION = 2
|
|
56
|
+
# Column types the policy compares without a cast, by Postgres internal
|
|
57
|
+
# name: a reach column of the protected table against
|
|
58
|
+
# super_auth_authorizations.resource_external_id must be identical or in
|
|
59
|
+
# one of these families.
|
|
60
|
+
TYPE_FAMILIES = [%w[int2 int4 int8], %w[text varchar]].freeze
|
|
61
|
+
# The 0.8.0 policy's `resource_external_id IS NULL OR resource_external_id
|
|
62
|
+
# = t.id`, as pg_get_expr deparses it (parenthesised) and as written.
|
|
63
|
+
V1_SHAPE = /IS NULL\)? OR/
|
|
29
64
|
|
|
30
65
|
class << self
|
|
31
|
-
# Enable RLS on an app table with
|
|
32
|
-
#
|
|
33
|
-
#
|
|
66
|
+
# Enable RLS on an app table with one policy mirroring ByCurrentUser.
|
|
67
|
+
# resource_type: names the types whose rows reach a row by its id, and
|
|
68
|
+
# parent: the columns through which other types reach it; both go
|
|
69
|
+
# through SuperAuth::Reach.normalize, as the ORM macro's do, so the two
|
|
70
|
+
# layers cannot drift. The USING expression is the transaction stamp
|
|
71
|
+
# AND (system context OR one step per reach entry): the type-level step
|
|
72
|
+
# admits every row to a holder of a (type, NULL) row, the id step and
|
|
73
|
+
# each column step admit the rows whose column is among the ids the
|
|
74
|
+
# holder's rows name. wildcard: false leaves the type-level step out,
|
|
75
|
+
# for a table whose types are never granted type-level; a (type, NULL)
|
|
76
|
+
# row then admits nothing there.
|
|
34
77
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
78
|
+
# INSERTs are gated too. The policy is FOR ALL with no WITH CHECK, so
|
|
79
|
+
# Postgres reuses USING for new rows: creating a row needs a type-level
|
|
80
|
+
# row for the table's type, a parent row for the value the new row
|
|
81
|
+
# carries in a parent column, or system context. A NULL parent column
|
|
82
|
+
# equals no id, so a parent grant never admits a row without one.
|
|
83
|
+
#
|
|
84
|
+
# The DDL runs in one transaction under lock_timeout: DROP and CREATE
|
|
85
|
+
# POLICY take ACCESS EXCLUSIVE, and as separate statements they left a
|
|
86
|
+
# window with no policy on a live table. Inside a transaction the
|
|
87
|
+
# caller already holds — a migration's — it joins that one, and the
|
|
88
|
+
# SET LOCAL lasts until that transaction ends. Every name in
|
|
89
|
+
# POLICY_NAMES is dropped, never altered, and the fresh policy is
|
|
90
|
+
# commented with its version and reach. Idempotent, and re-runnable on
|
|
91
|
+
# a protected table.
|
|
92
|
+
def enable(table, resource_type:, parent: nil, wildcard: true, lock_timeout: "5s", db: SuperAuth.db)
|
|
41
93
|
postgres!(db)
|
|
94
|
+
reach = Reach.normalize(resource_type: resource_type, parent: parent)
|
|
95
|
+
unless [true, false].include?(wildcard)
|
|
96
|
+
raise SuperAuth::Error, "wildcard: must be true or false, got #{wildcard.inspect}"
|
|
97
|
+
end
|
|
98
|
+
preflight!(table, reach, db)
|
|
42
99
|
create_functions(db)
|
|
100
|
+
# SuperAuth.as memoises whether they exist; this is what creates them.
|
|
101
|
+
SuperAuth.rls!
|
|
43
102
|
grant_runtime_reads(db)
|
|
44
103
|
t = db.literal(Sequel.identifier(table.to_s))
|
|
45
|
-
db.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
104
|
+
db.transaction do
|
|
105
|
+
db.run "SET LOCAL lock_timeout = #{db.literal(lock_timeout.to_s)}"
|
|
106
|
+
db.run "ALTER TABLE #{t} ENABLE ROW LEVEL SECURITY"
|
|
107
|
+
# FORCE: apply the policy even when the app connects as the table owner
|
|
108
|
+
db.run "ALTER TABLE #{t} FORCE ROW LEVEL SECURITY"
|
|
109
|
+
POLICY_NAMES.each { |name| db.run "DROP POLICY IF EXISTS #{name} ON #{t}" }
|
|
110
|
+
db.run "CREATE POLICY #{POLICY} ON #{t}\nUSING (\n#{using(t, reach, wildcard, db)}\n)"
|
|
111
|
+
db.run "COMMENT ON POLICY #{POLICY} ON #{t} IS #{db.literal(comment(reach, wildcard))}"
|
|
112
|
+
end
|
|
70
113
|
end
|
|
71
114
|
|
|
72
|
-
# Drops the table's policy
|
|
73
|
-
# (other tables may still be
|
|
74
|
-
# own).
|
|
75
|
-
def disable(table, db: SuperAuth.db)
|
|
115
|
+
# Drops the table's policy under every name enable ever used; the
|
|
116
|
+
# shared functions are left in place (other tables may still be
|
|
117
|
+
# protected, and they are harmless on their own).
|
|
118
|
+
def disable(table, lock_timeout: "5s", db: SuperAuth.db)
|
|
76
119
|
postgres!(db)
|
|
77
120
|
t = db.literal(Sequel.identifier(table.to_s))
|
|
78
|
-
db.
|
|
79
|
-
|
|
80
|
-
|
|
121
|
+
db.transaction do
|
|
122
|
+
db.run "SET LOCAL lock_timeout = #{db.literal(lock_timeout.to_s)}"
|
|
123
|
+
POLICY_NAMES.each { |name| db.run "DROP POLICY IF EXISTS #{name} ON #{t}" }
|
|
124
|
+
db.run "ALTER TABLE #{t} NO FORCE ROW LEVEL SECURITY"
|
|
125
|
+
db.run "ALTER TABLE #{t} DISABLE ROW LEVEL SECURITY"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether the table carries the policy this enable would build for the
|
|
130
|
+
# same arguments: row security on and forced, the policy's comment
|
|
131
|
+
# equal to the one enable writes (version, reach and wildcard in one
|
|
132
|
+
# canonical JSON string), and its expression free of the 0.8.0 shape,
|
|
133
|
+
# whose `IS NULL OR` was evaluated once per row. For a test helper or a
|
|
134
|
+
# health check after a deploy that changed parent: or upgraded the gem.
|
|
135
|
+
#
|
|
136
|
+
# False means "not built from these arguments" and nothing worse: the
|
|
137
|
+
# table has no policy of the gem's yet, row security is off or unforced,
|
|
138
|
+
# or the reach really does disagree. A policy an *earlier* version of
|
|
139
|
+
# enable built raises instead, with the message `reach` and `coverage`
|
|
140
|
+
# give, because the question this asks has no answer there — the
|
|
141
|
+
# comparison is against a comment written by code that is gone, so a
|
|
142
|
+
# bare false says "your parent:/wildcard: arguments are wrong" about a
|
|
143
|
+
# database whose only fault is that nobody re-ran enable after the
|
|
144
|
+
# upgrade. That is the state a host lands in by running db:migrate and
|
|
145
|
+
# nothing else, since no migration re-runs enable, and it is the
|
|
146
|
+
# expensive one to be in unawares: the 0.8.0 policy is still installed
|
|
147
|
+
# and still correlated per row. `stale` names every table in it without
|
|
148
|
+
# raising, and is the call to make first after an upgrade.
|
|
149
|
+
def current?(table, resource_type:, parent: nil, wildcard: true, db: SuperAuth.db)
|
|
150
|
+
postgres!(db)
|
|
151
|
+
reach = Reach.normalize(resource_type: resource_type, parent: parent)
|
|
152
|
+
row = policy(table, db)
|
|
153
|
+
return false if row.nil?
|
|
154
|
+
|
|
155
|
+
assert_policy_version!(table, row[:comment])
|
|
156
|
+
row[:enabled] && row[:forced] &&
|
|
157
|
+
row[:comment] == comment(reach, wildcard) && !row[:qual].to_s.match?(V1_SHAPE)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Every table carrying a policy of the gem's whose comment is missing
|
|
161
|
+
# or records a version other than POLICY_VERSION: the tables an upgrade
|
|
162
|
+
# has to re-run enable on. Table names as symbols, sorted.
|
|
163
|
+
def stale(db: SuperAuth.db)
|
|
164
|
+
postgres!(db)
|
|
165
|
+
policies(db).reject { |row| version(row[:comment]) == POLICY_VERSION }.map { |row| row[:table].to_sym }.uniq.sort
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The reach map the table's policy was built from, read back from its
|
|
169
|
+
# comment, so coverage and explain work with no model loaded. Raises
|
|
170
|
+
# when the table has no policy or one an earlier enable built.
|
|
171
|
+
def reach(table, db: SuperAuth.db)
|
|
172
|
+
postgres!(db)
|
|
173
|
+
metadata(table, db)[:reach]
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Which compiled rows admit record `id` of `table` for the identity
|
|
177
|
+
# currently asserted on the connection, each row a hash of its columns
|
|
178
|
+
# plus :step — :type_level, :id, or the parent column — for the step
|
|
179
|
+
# that admitted it. Nothing comes back with no identity asserted, under
|
|
180
|
+
# system context (the system clause admits without a row), or for a
|
|
181
|
+
# record that does not exist. Reads the table itself for the record's
|
|
182
|
+
# parent columns, in system context when the role may assert it, so a
|
|
183
|
+
# role that may not sees a record it is not admitted to as absent (see
|
|
184
|
+
# `reading`).
|
|
185
|
+
def explain(table, id, db: SuperAuth.db)
|
|
186
|
+
postgres!(db)
|
|
187
|
+
meta = metadata(table, db)
|
|
188
|
+
reach = meta[:reach]
|
|
189
|
+
holder = identity(db)[0, 3]
|
|
190
|
+
reading(db) do
|
|
191
|
+
as_holder(holder, db)
|
|
192
|
+
record = db[Sequel.identifier(table.to_s)].where(id: id).select(:id, *Reach.parents(reach).keys).first
|
|
193
|
+
rows = []
|
|
194
|
+
if record
|
|
195
|
+
rows.concat(tag(:type_level, holdings_of(type_level_where(reach[:id], db), db))) if meta[:wildcard]
|
|
196
|
+
reach.each do |column, types|
|
|
197
|
+
value = record[column]
|
|
198
|
+
next if value.nil?
|
|
199
|
+
rows.concat(tag(column, holdings_of("#{per_record_where(types, db)} AND a.resource_external_id = #{db.literal(value)}", db)))
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
rows
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# What the table's compiled rows and nodes look like against its policy
|
|
207
|
+
# — a diagnostic for a host moving tenancy from per-record rows to a
|
|
208
|
+
# parent column, or checking a production dump before it does. Five
|
|
209
|
+
# buckets, each an Array of small hashes with a :count and up to 20
|
|
210
|
+
# example :ids, and only the entries whose count is above zero:
|
|
211
|
+
#
|
|
212
|
+
# loss per holder of a type-level row on one of the table's
|
|
213
|
+
# own types: the records that holder reaches through
|
|
214
|
+
# that row and nothing else — no per-record row, no
|
|
215
|
+
# parent row for the value in any parent column, NULL
|
|
216
|
+
# parents included. What deleting the type-level row
|
|
217
|
+
# takes away. ids are the table's.
|
|
218
|
+
# null_parent per parent column: the records whose column is NULL,
|
|
219
|
+
# which no parent grant can reach. ids are the table's.
|
|
220
|
+
# orphaned_rows compiled rows with nothing behind them: the node
|
|
221
|
+
# they were compiled from is gone, or no longer names
|
|
222
|
+
# the (type, id) the row copies — a (type, NULL) row
|
|
223
|
+
# whose wildcard node was deleted keeps admitting every
|
|
224
|
+
# record of the type until the next compile. Grouped by
|
|
225
|
+
# type and whether the rows are type-level; ids are
|
|
226
|
+
# super_auth_resources ids.
|
|
227
|
+
# widening per holder of a parent-type row: the records that
|
|
228
|
+
# holder reaches through a parent column and holds no
|
|
229
|
+
# per-record row for, so the parent step admits them
|
|
230
|
+
# for the first time. A holder a type-level row already
|
|
231
|
+
# admits everywhere is left out. ids are the table's.
|
|
232
|
+
# deletable_nodes per own type: the per-record nodes no user->resource
|
|
233
|
+
# edge points at, on the node itself or on any
|
|
234
|
+
# ancestor of it. Access granted through a permission
|
|
235
|
+
# edge travels with the permission and can be replaced
|
|
236
|
+
# by the parent column; access granted straight to a
|
|
237
|
+
# user — an owner, a veteran with one read grant — has
|
|
238
|
+
# no other path and its node must stay. A node with
|
|
239
|
+
# children is not listed until they are decided. ids
|
|
240
|
+
# are super_auth_resources ids.
|
|
241
|
+
#
|
|
242
|
+
# The holder-to-row match is the SQL the policy runs, with the identity
|
|
243
|
+
# settings pointed at each holder in turn, so what coverage counts as
|
|
244
|
+
# reached is exactly what the policy admits. A type-level row on a
|
|
245
|
+
# parent type is never counted as reaching anything: a column holds an
|
|
246
|
+
# id, and NULL equals none. Reads run in system context when the role
|
|
247
|
+
# may assert it, and otherwise as the identity the caller has, which
|
|
248
|
+
# sees only its own rows (see `reading`); either way the role needs
|
|
249
|
+
# SELECT on the table and on super_auth_resources and super_auth_edges,
|
|
250
|
+
# which enable grants to nobody. `explain` needs neither of those two:
|
|
251
|
+
# it reads only the table and super_auth_authorizations, which enable
|
|
252
|
+
# grants to PUBLIC.
|
|
253
|
+
def coverage(table, db: SuperAuth.db)
|
|
254
|
+
postgres!(db)
|
|
255
|
+
meta = metadata(table, db)
|
|
256
|
+
reach = meta[:reach]
|
|
257
|
+
parents = Reach.parents(reach)
|
|
258
|
+
t = Sequel.identifier(table.to_s)
|
|
259
|
+
tq = db.literal(t)
|
|
260
|
+
reading(db) do
|
|
261
|
+
# null_parent is counted first and held in a local: loss and
|
|
262
|
+
# widening point the identity settings at each holder in turn and
|
|
263
|
+
# leave them there, so a bucket that samples the table after them
|
|
264
|
+
# would read it as that last holder rather than as the caller. The
|
|
265
|
+
# returned Hash keeps the documented order.
|
|
266
|
+
null_parent = parents.keys.filter_map { |column|
|
|
267
|
+
entry = sample(t, "#{tq}.#{db.literal(Sequel.identifier(column.to_s))} IS NULL", db)
|
|
268
|
+
{ column: column, **entry } if entry[:count] > 0
|
|
269
|
+
}
|
|
270
|
+
{
|
|
271
|
+
loss: loss(t, tq, reach, meta[:wildcard], db),
|
|
272
|
+
null_parent: null_parent,
|
|
273
|
+
orphaned_rows: orphaned_rows(reach, db),
|
|
274
|
+
widening: widening(t, tq, reach, meta[:wildcard], db),
|
|
275
|
+
deletable_nodes: deletable_nodes(reach, db),
|
|
276
|
+
}
|
|
277
|
+
end
|
|
81
278
|
end
|
|
82
279
|
|
|
83
280
|
# Run the block with `user`'s identity asserted for one transaction —
|
|
@@ -156,6 +353,341 @@ module SuperAuth
|
|
|
156
353
|
|
|
157
354
|
private
|
|
158
355
|
|
|
356
|
+
# ---- The policy text. Each clause is one method, and the same methods
|
|
357
|
+
# build the coverage and explain queries, so the shapes cannot drift.
|
|
358
|
+
|
|
359
|
+
# Identity from this transaction only.
|
|
360
|
+
STAMP = "current_setting('super_auth.xid', true) = pg_current_xact_id()::text".freeze
|
|
361
|
+
SYSTEM = "COALESCE(current_setting('super_auth.system', true), '') = 'true'".freeze
|
|
362
|
+
# The two halves of "this compiled row belongs to the asserted
|
|
363
|
+
# identity", with `a` the super_auth_authorizations alias. The column is
|
|
364
|
+
# cast to text rather than the setting to the column's type, because a
|
|
365
|
+
# setting is free text and casting it fails in both directions.
|
|
366
|
+
# Postgres folds the cast at PLAN time — a bare EXPLAIN with no ANALYZE,
|
|
367
|
+
# a zero-row unindexed table and LIMIT 0 each raise 22P02 on a malformed
|
|
368
|
+
# identity, 22003 on an out-of-range integer, and then 25P02 for every
|
|
369
|
+
# later statement in the transaction — so no clause order, guard, index
|
|
370
|
+
# or row count avoids it, and a denied caller kills the transaction
|
|
371
|
+
# instead of seeing no rows. And on the default :string install the cast
|
|
372
|
+
# target format_type reports is varchar(255), which truncates silently:
|
|
373
|
+
# a 270-character asserted identity then matches a stored 255-character
|
|
374
|
+
# one, fail open, an authorization-widening bug. A total SQL function
|
|
375
|
+
# over the setting, regex-guarded and returning NULL rather than
|
|
376
|
+
# raising, is the one variant neither fact kills; it was proposed after
|
|
377
|
+
# the measurements were taken, so it is unmeasured in combination, and
|
|
378
|
+
# it would still need an index on user_id because `holdings` emits both
|
|
379
|
+
# halves unconditionally. A candidate for a later release, not this one.
|
|
380
|
+
INTERNAL_USER = "a.user_id::text = NULLIF(current_setting('super_auth.user_id', true), '')".freeze
|
|
381
|
+
EXTERNAL_USER = "a.user_external_id::text = NULLIF(current_setting('super_auth.user_external_id', true), '') " \
|
|
382
|
+
"AND a.user_external_type = NULLIF(current_setting('super_auth.user_external_type', true), '')".freeze
|
|
383
|
+
|
|
384
|
+
# The stamp, then system context or any step. Every step is
|
|
385
|
+
# uncorrelated with the outer row — the id and column steps compare the
|
|
386
|
+
# row's column against the set of ids the holder's rows name, the
|
|
387
|
+
# type-level step is a bare EXISTS — so Postgres evaluates each once
|
|
388
|
+
# per query (an InitPlan, a hashed SubPlan) instead of once per row.
|
|
389
|
+
# The 0.8.0 policy correlated one EXISTS on `IS NULL OR = t.id` and
|
|
390
|
+
# re-ran it for every row of the table, seconds against a holder with
|
|
391
|
+
# thousands of rows; this shape is milliseconds.
|
|
392
|
+
def using(t, reach, wildcard, db)
|
|
393
|
+
steps = [SYSTEM]
|
|
394
|
+
steps << type_level_step(reach[:id], db) if wildcard
|
|
395
|
+
reach.each { |column, types| steps << column_step(t, column, types, db) }
|
|
396
|
+
" #{STAMP}\n AND (\n #{steps.join("\n OR ")}\n )"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# `select` from the holder's compiled rows matching `where`, the two
|
|
400
|
+
# identity halves as a UNION ALL rather than an OR inside one WHERE, so
|
|
401
|
+
# each half can walk an index of its own: idx_sa_auth_by_internal_user_text
|
|
402
|
+
# for user_id, and for the external half idx_sa_auth_by_current_user
|
|
403
|
+
# (migration 9) where external_id_type is a text type and
|
|
404
|
+
# idx_sa_auth_by_current_user_text where it is not. Both halves are
|
|
405
|
+
# emitted whatever kind of identity is asserted, so every install pays
|
|
406
|
+
# for the internal one and an index on user_id is not optional.
|
|
407
|
+
def holdings(select, where)
|
|
408
|
+
[INTERNAL_USER, EXTERNAL_USER].map { |user|
|
|
409
|
+
"SELECT #{select} FROM super_auth_authorizations a WHERE #{where} AND #{user}"
|
|
410
|
+
}.join(" UNION ALL ")
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def type_level_where(types, db)
|
|
414
|
+
"a.resource_external_type IN (#{types.map { |type| db.literal(type) }.join(', ')}) AND a.resource_external_id IS NULL"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def per_record_where(types, db)
|
|
418
|
+
"a.resource_external_type IN (#{types.map { |type| db.literal(type) }.join(', ')}) AND a.resource_external_id IS NOT NULL"
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def type_level_step(types, db)
|
|
422
|
+
"EXISTS (#{holdings('1', type_level_where(types, db))})"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def column_step(t, column, types, db)
|
|
426
|
+
"#{t}.#{db.literal(Sequel.identifier(column.to_s))} IN (#{holdings('a.resource_external_id', per_record_where(types, db))})"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# The policy's comment: version, reach and wildcard as JSON with the
|
|
430
|
+
# keys in one fixed order, so current? compares strings.
|
|
431
|
+
def comment(reach, wildcard)
|
|
432
|
+
JSON.generate("super_auth" => POLICY_VERSION, "reach" => reach.to_h { |column, types| [column.to_s, types] }, "wildcard" => wildcard)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# ---- Preflight.
|
|
436
|
+
|
|
437
|
+
# Refuses, before any DDL, a reach column the policy could not compare:
|
|
438
|
+
# one the table lacks, or one outside the type family of
|
|
439
|
+
# super_auth_authorizations.resource_external_id. CREATE POLICY refuses
|
|
440
|
+
# the second on its own, with "operator does not exist: integer =
|
|
441
|
+
# character varying" and no hint that external_id_type is the setting
|
|
442
|
+
# that decides the other side.
|
|
443
|
+
def preflight!(table, reach, db)
|
|
444
|
+
reference = column_types(:super_auth_authorizations, [:resource_external_id], db)[:resource_external_id]
|
|
445
|
+
unless reference
|
|
446
|
+
raise SuperAuth::Error, "super_auth_authorizations.resource_external_id does not exist; run the super_auth migrations before enable"
|
|
447
|
+
end
|
|
448
|
+
columns = column_types(table, reach.keys, db)
|
|
449
|
+
setting = "SuperAuth.external_id_type is #{SuperAuth.external_id_type.inspect}"
|
|
450
|
+
reach.each_key do |column|
|
|
451
|
+
type = columns[column]
|
|
452
|
+
unless type
|
|
453
|
+
through = column == :id ? "reaches rows by it" : "reaches rows through it as a parent: column holding the id of the record a row belongs to"
|
|
454
|
+
raise SuperAuth::Error, "#{table} has no column #{column}, and the policy #{through}; " \
|
|
455
|
+
"super_auth_authorizations.resource_external_id is #{reference[1]} (#{setting})"
|
|
456
|
+
end
|
|
457
|
+
next if same_family?(type[0], reference[0])
|
|
458
|
+
raise SuperAuth::Error, "#{table}.#{column} is #{type[1]} and super_auth_authorizations.resource_external_id is #{reference[1]}; " \
|
|
459
|
+
"the policy compares them with no cast, so they must be the same type, both integer types or both text types. " \
|
|
460
|
+
"#{setting}: set it to the type of your tables' ids before running the super_auth migrations, " \
|
|
461
|
+
"or alter the four external id columns to match"
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# { column => [internal type name, SQL type name] } for the columns the
|
|
466
|
+
# table has among `columns`. Raises for a table that does not exist —
|
|
467
|
+
# a missing table would otherwise read as every column missing.
|
|
468
|
+
def column_types(table, columns, db)
|
|
469
|
+
rel = db.literal(Sequel.identifier(table.to_s))
|
|
470
|
+
unless db.get(Sequel.function(:to_regclass, rel))
|
|
471
|
+
raise SuperAuth::Error, "table #{table} does not exist"
|
|
472
|
+
end
|
|
473
|
+
db.fetch(<<~SQL).to_h { |row| [row[:name].to_sym, [row[:internal], row[:sql]]] }
|
|
474
|
+
SELECT a.attname AS name, t.typname AS internal, format_type(a.atttypid, a.atttypmod) AS sql
|
|
475
|
+
FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
|
|
476
|
+
WHERE a.attrelid = to_regclass(#{db.literal(rel)}) AND a.attnum > 0 AND NOT a.attisdropped
|
|
477
|
+
AND a.attname IN (#{columns.map { |column| db.literal(column.to_s) }.join(', ')})
|
|
478
|
+
SQL
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def same_family?(a, b)
|
|
482
|
+
a == b || TYPE_FAMILIES.any? { |family| family.include?(a) && family.include?(b) }
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# ---- The catalogue: what is installed on a table.
|
|
486
|
+
|
|
487
|
+
def policies(db, table: nil)
|
|
488
|
+
scope = table ? "AND c.oid = to_regclass(#{db.literal(db.literal(Sequel.identifier(table.to_s)))})" : ""
|
|
489
|
+
db.fetch(<<~SQL).all
|
|
490
|
+
SELECT c.relname AS "table", p.polname AS name,
|
|
491
|
+
obj_description(p.oid, 'pg_policy') AS comment,
|
|
492
|
+
pg_get_expr(p.polqual, p.polrelid) AS qual,
|
|
493
|
+
c.relrowsecurity AS enabled, c.relforcerowsecurity AS forced
|
|
494
|
+
FROM pg_policy p JOIN pg_class c ON c.oid = p.polrelid
|
|
495
|
+
WHERE p.polname IN (#{POLICY_NAMES.map { |name| db.literal(name) }.join(', ')}) #{scope}
|
|
496
|
+
SQL
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# The table's policy under the current name, or nil.
|
|
500
|
+
def policy(table, db)
|
|
501
|
+
policies(db, table: table).find { |row| row[:name] == POLICY }
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# The comment as a Hash, or nil for no comment or one that is not the
|
|
505
|
+
# gem's JSON.
|
|
506
|
+
def parse(comment)
|
|
507
|
+
parsed = JSON.parse(comment.to_s)
|
|
508
|
+
parsed if parsed.is_a?(Hash)
|
|
509
|
+
rescue JSON::ParserError
|
|
510
|
+
nil
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def version(comment)
|
|
514
|
+
parse(comment)&.dig("super_auth")
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# One message for every caller that cannot work with a policy an
|
|
518
|
+
# earlier enable built: metadata (so reach, coverage and explain) and
|
|
519
|
+
# current?. It names the action, since there is exactly one.
|
|
520
|
+
def stale_policy_message(table)
|
|
521
|
+
"the #{POLICY} policy on #{table} was not built by this version of enable " \
|
|
522
|
+
"(policy version #{POLICY_VERSION}); re-run SuperAuth::RLS.enable"
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def assert_policy_version!(table, comment)
|
|
526
|
+
return if version(comment) == POLICY_VERSION
|
|
527
|
+
|
|
528
|
+
raise SuperAuth::Error, stale_policy_message(table)
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Reach and wildcard from the policy's comment, the reach re-normalised
|
|
532
|
+
# so it is the same frozen shape enable built from.
|
|
533
|
+
def metadata(table, db)
|
|
534
|
+
row = policy(table, db)
|
|
535
|
+
raise SuperAuth::Error, "#{table} has no #{POLICY} policy; run SuperAuth::RLS.enable first" unless row
|
|
536
|
+
parsed = parse(row[:comment])
|
|
537
|
+
stored = parsed && parsed["reach"]
|
|
538
|
+
unless parsed && parsed["super_auth"] == POLICY_VERSION && stored.is_a?(Hash) && stored.key?("id") && [true, false].include?(parsed["wildcard"])
|
|
539
|
+
raise SuperAuth::Error, stale_policy_message(table)
|
|
540
|
+
end
|
|
541
|
+
parent = stored.reject { |column, _| column == "id" }.map { |column, types| { column: column, resource_type: types } }
|
|
542
|
+
{ reach: Reach.normalize(resource_type: stored["id"], parent: parent), wildcard: parsed["wildcard"] }
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# ---- coverage and explain.
|
|
546
|
+
|
|
547
|
+
# Runs the block in a transaction whose reads of the protected table
|
|
548
|
+
# are not filtered by the asserted identity, where the role allows it:
|
|
549
|
+
# system context through super_auth_system() when the calling role may
|
|
550
|
+
# execute it and is not one Postgres exempts from row security anyway
|
|
551
|
+
# (a superuser or BYPASSRLS role reads everything, and the function
|
|
552
|
+
# refuses it). A role with neither runs as it is, and its reads see only
|
|
553
|
+
# what its identity sees. Joins an enclosing transaction and puts its
|
|
554
|
+
# identity back at the end, as `as` does, since the block re-points the
|
|
555
|
+
# identity settings.
|
|
556
|
+
def reading(db)
|
|
557
|
+
enclosing = db.in_transaction? ? identity(db) : nil
|
|
558
|
+
db.transaction do
|
|
559
|
+
db.get(Sequel.function(:super_auth_system)) if bypass_available?(db)
|
|
560
|
+
begin
|
|
561
|
+
yield
|
|
562
|
+
ensure
|
|
563
|
+
restore(enclosing, db) if enclosing
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def bypass_available?(db)
|
|
569
|
+
db.get(Sequel.lit(<<~SQL))
|
|
570
|
+
COALESCE(has_function_privilege(current_user, to_regprocedure('super_auth_system()'), 'EXECUTE'), false)
|
|
571
|
+
AND NOT (SELECT rolsuper OR rolbypassrls FROM pg_roles WHERE rolname = current_user)
|
|
572
|
+
SQL
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Points the three identity settings at one holder — user_id,
|
|
576
|
+
# user_external_id, user_external_type — so the policy's own predicate
|
|
577
|
+
# matches that holder's rows. System context, when asserted, stays.
|
|
578
|
+
def as_holder(values, db)
|
|
579
|
+
db.dataset.get(SETTINGS[0, 3].zip(values).each_with_index.map { |(name, value), i| Sequel.function(:set_config, name, value.to_s, true).as(:"s#{i}") })
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def holdings_of(where, db)
|
|
583
|
+
db.fetch(holdings("a.*", where)).all
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def tag(step, rows)
|
|
587
|
+
rows.map { |row| row.merge(step: step) }
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# The distinct identities holding a compiled row matching `where`.
|
|
591
|
+
def holders_of(where, db)
|
|
592
|
+
db.fetch("SELECT DISTINCT a.user_id, a.user_external_id, a.user_external_type FROM super_auth_authorizations a WHERE #{where} ORDER BY 1, 2, 3")
|
|
593
|
+
.map { |row| [row[:user_id], row[:user_external_id], row[:user_external_type]] }
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def holder_hash(values)
|
|
597
|
+
{ user_id: values[0], user_external_id: values[1], user_external_type: values[2] }
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Count and up to 20 ids of the table's rows matching `where`.
|
|
601
|
+
def sample(t, where, db)
|
|
602
|
+
ds = db[t].where(Sequel.lit(where))
|
|
603
|
+
{ count: ds.count, ids: ds.order(:id).limit(20).select_map(:id) }
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Nothing to lose under wildcard: false. The type-level step is not in
|
|
607
|
+
# the policy there, so a (type, NULL) row admits nothing and deleting it
|
|
608
|
+
# takes nothing away; the bucket is defined by exclusion, so without
|
|
609
|
+
# this it would list every record the holder cannot reach at all.
|
|
610
|
+
def loss(t, tq, reach, wildcard, db)
|
|
611
|
+
return [] unless wildcard
|
|
612
|
+
|
|
613
|
+
holders_of(type_level_where(reach[:id], db), db).filter_map do |holder|
|
|
614
|
+
as_holder(holder, db)
|
|
615
|
+
unreached = ["NOT (#{column_step(tq, :id, reach[:id], db)})"]
|
|
616
|
+
Reach.parents(reach).each do |column, types|
|
|
617
|
+
unreached << "(#{tq}.#{db.literal(Sequel.identifier(column.to_s))} IS NULL OR NOT (#{column_step(tq, column, types, db)}))"
|
|
618
|
+
end
|
|
619
|
+
entry = sample(t, unreached.join(" AND "), db)
|
|
620
|
+
{ holder: holder_hash(holder), **entry } if entry[:count] > 0
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def widening(t, tq, reach, wildcard, db)
|
|
625
|
+
parents = Reach.parents(reach)
|
|
626
|
+
return [] if parents.empty?
|
|
627
|
+
holders_of(per_record_where(parents.values.flatten.uniq, db), db).filter_map do |holder|
|
|
628
|
+
as_holder(holder, db)
|
|
629
|
+
via_parent = parents.map { |column, types| column_step(tq, column, types, db) }.join(" OR ")
|
|
630
|
+
where = "(#{via_parent}) AND NOT (#{column_step(tq, :id, reach[:id], db)})"
|
|
631
|
+
where += " AND NOT #{type_level_step(reach[:id], db)}" if wildcard
|
|
632
|
+
entry = sample(t, where, db)
|
|
633
|
+
{ holder: holder_hash(holder), **entry } if entry[:count] > 0
|
|
634
|
+
end
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def orphaned_rows(reach, db)
|
|
638
|
+
types = reach.values.flatten.uniq.map { |type| db.literal(type) }.join(", ")
|
|
639
|
+
where = <<~SQL
|
|
640
|
+
a.resource_external_type IN (#{types})
|
|
641
|
+
AND NOT EXISTS (
|
|
642
|
+
SELECT 1 FROM super_auth_resources r
|
|
643
|
+
WHERE r.id = a.resource_id
|
|
644
|
+
AND r.external_type = a.resource_external_type
|
|
645
|
+
AND r.external_id IS NOT DISTINCT FROM a.resource_external_id
|
|
646
|
+
)
|
|
647
|
+
SQL
|
|
648
|
+
groups = db.fetch(<<~SQL).all
|
|
649
|
+
SELECT a.resource_external_type AS type, (a.resource_external_id IS NULL) AS type_level, count(*) AS count
|
|
650
|
+
FROM super_auth_authorizations a WHERE #{where}
|
|
651
|
+
GROUP BY 1, 2 ORDER BY 1, 2
|
|
652
|
+
SQL
|
|
653
|
+
groups.map do |group|
|
|
654
|
+
ids = db.fetch(<<~SQL).map(:id)
|
|
655
|
+
SELECT DISTINCT a.resource_id AS id FROM super_auth_authorizations a
|
|
656
|
+
WHERE #{where} AND a.resource_external_type = #{db.literal(group[:type])}
|
|
657
|
+
AND (a.resource_external_id IS NULL) = #{group[:type_level]}
|
|
658
|
+
ORDER BY 1 LIMIT 20
|
|
659
|
+
SQL
|
|
660
|
+
{ type: group[:type], type_level: group[:type_level], count: group[:count], ids: ids }
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# The user-edge test walks up parent_id, not just the node itself: the
|
|
665
|
+
# compile copies a granted container's edges down to every registered
|
|
666
|
+
# descendant, so a user edge on an ancestor is a user's only path to
|
|
667
|
+
# this record just as one on the node would be, and deleting the node
|
|
668
|
+
# revokes it. The walk terminates because the tree is acyclic-guarded.
|
|
669
|
+
def deletable_nodes(reach, db)
|
|
670
|
+
where = <<~SQL
|
|
671
|
+
r.external_type IN (#{reach[:id].map { |type| db.literal(type) }.join(', ')})
|
|
672
|
+
AND r.external_id IS NOT NULL
|
|
673
|
+
AND NOT EXISTS (
|
|
674
|
+
WITH RECURSIVE up AS (
|
|
675
|
+
SELECT r.id, r.parent_id
|
|
676
|
+
UNION ALL
|
|
677
|
+
SELECT p.id, p.parent_id FROM super_auth_resources p JOIN up ON p.id = up.parent_id
|
|
678
|
+
)
|
|
679
|
+
SELECT 1 FROM up JOIN super_auth_edges e ON e.resource_id = up.id WHERE e.user_id IS NOT NULL
|
|
680
|
+
)
|
|
681
|
+
AND NOT EXISTS (SELECT 1 FROM super_auth_resources child WHERE child.parent_id = r.id)
|
|
682
|
+
SQL
|
|
683
|
+
db.fetch("SELECT r.external_type AS type, count(*) AS count FROM super_auth_resources r WHERE #{where} GROUP BY 1 ORDER BY 1").map do |group|
|
|
684
|
+
ids = db.fetch("SELECT r.id FROM super_auth_resources r WHERE #{where} AND r.external_type = #{db.literal(group[:type])} ORDER BY r.id LIMIT 20").map(:id)
|
|
685
|
+
{ type: group[:type], count: group[:count], ids: ids }
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# ---- Installation.
|
|
690
|
+
|
|
159
691
|
# What any runtime role needs on the gem's own tables: the policies read
|
|
160
692
|
# super_auth_authorizations as the querying role, and the user models'
|
|
161
693
|
# system? reads super_auth_users. Granting PUBLIC makes enable the only
|