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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +440 -42
  5. data/USAGE.md +127 -21
  6. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  7. data/db/migrate/12_add_resource_indexes.rb +116 -0
  8. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  9. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  10. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  11. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  12. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  13. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  14. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  16. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  17. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  18. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  19. data/db/seeds/sample_data.rb +1 -0
  20. data/lib/generators/super_auth/install/templates/README +6 -2
  21. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  22. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  23. data/lib/super_auth/active_record/authorization.rb +14 -3
  24. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  25. data/lib/super_auth/active_record/group.rb +3 -0
  26. data/lib/super_auth/active_record/nested.rb +43 -0
  27. data/lib/super_auth/active_record/resource.rb +28 -4
  28. data/lib/super_auth/active_record/role.rb +3 -0
  29. data/lib/super_auth/active_record.rb +30 -2
  30. data/lib/super_auth/authorization.rb +63 -10
  31. data/lib/super_auth/edge.rb +73 -18
  32. data/lib/super_auth/editor/index.html +16 -10
  33. data/lib/super_auth/editor/seed.rb +11 -5
  34. data/lib/super_auth/editor.rb +35 -11
  35. data/lib/super_auth/nestable.rb +105 -4
  36. data/lib/super_auth/reach.rb +88 -0
  37. data/lib/super_auth/resource.rb +71 -0
  38. data/lib/super_auth/rls.rb +576 -44
  39. data/lib/super_auth/tree_guard.rb +115 -0
  40. data/lib/super_auth/version.rb +1 -1
  41. data/lib/super_auth.rb +55 -2
  42. metadata +10 -1
@@ -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 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
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 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.
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
- # 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)
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.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
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; 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)
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.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"
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