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
@@ -0,0 +1,15 @@
1
+ class AddResourceTreeGuardToSuperAuthResources < ActiveRecord::Migration[7.0]
2
+ # Mirrors db/migrate/13_add_resource_tree_guard.rb, which says why: a
3
+ # parent_id cycle silently widens grants, the models and compile! refuse
4
+ # it, and this trigger refuses it for writes that go around them. Postgres
5
+ # only; a no-op elsewhere. The SQL lives in SuperAuth::TreeGuard so a host
6
+ # can install it from its test setup as well: db/schema.rb cannot carry a
7
+ # trigger, so a test database built from it has none.
8
+ def up
9
+ SuperAuth::TreeGuard.install(db: SuperAuth.db)
10
+ end
11
+
12
+ def down
13
+ SuperAuth::TreeGuard.remove(db: SuperAuth.db)
14
+ end
15
+ end
@@ -29,6 +29,7 @@ puts "Clearing existing data..."
29
29
  Edge.delete_all
30
30
  Group.update_all(parent_id: nil)
31
31
  Role.update_all(parent_id: nil)
32
+ Resource.update_all(parent_id: nil)
32
33
  User.delete_all
33
34
  Group.delete_all
34
35
  Role.delete_all
@@ -4,11 +4,15 @@ SuperAuth initializer created at config/initializers/super_auth.rb
4
4
 
5
5
  Next steps:
6
6
 
7
- 1. Run migrations to create the database tables:
7
+ 1. Copy the engine's migrations into your app and run them:
8
8
 
9
+ rails super_auth:install:migrations
9
10
  rails db:migrate
10
11
 
11
- (the engine adds its ActiveRecord migrations to your app)
12
+ (the engine does not run its migrations by itself; repeat both after an
13
+ upgrade that ships a new one, and re-run SuperAuth::RLS.enable for any
14
+ table under row-level security, since a policy already in the database
15
+ does not change with the gem — SuperAuth::RLS.stale lists the ones behind)
12
16
 
13
17
  2. Mount the engine in config/routes.rb, inside your own authentication.
14
18
  It serves the graph editor, which has no authentication of its own:
@@ -13,8 +13,11 @@ SuperAuth.setup do |config|
13
13
  # Default is :none (returns empty results silently).
14
14
  # config.missing_user_behavior = :raise
15
15
 
16
- # Postgres row-level security: enable it per table with
16
+ # Wrap request work in SuperAuth.as(current_user) { ... } — in an
17
+ # around_action, and around jobs — so the ByCurrentUser scope has a user.
18
+ #
19
+ # Postgres row-level security is optional and off until you ask for it:
17
20
  # rails generate super_auth:rls Model ...
18
- # then wrap request work in SuperAuth.as(current_user) { ... } wherever
19
- # database-level enforcement should apply.
21
+ # Once that migration has run, the same SuperAuth.as call also asserts the
22
+ # identity the policies read, so turning it on costs no application change.
20
23
  end
@@ -2,6 +2,8 @@ class EnableSuperAuthRls < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
2
2
  def up
3
3
  <% model_names.each do |model| -%>
4
4
  SuperAuth::RLS.enable(:<%= model.tableize.tr('/', '_') %>, resource_type: "<%= model.camelize %>")
5
+ # Tenancy, not capability: a parent grant admits every row whose column holds a granted record's id, so list every type that may touch the row at all and let the ORM decide who writes.
6
+ # parent: { column: :organization_id, resource_type: ["Organization::Member"] }
5
7
  <% end -%>
6
8
  # Roles allowed to bypass the policies (migrations, seeds, admin jobs):
7
9
  # SuperAuth::RLS.grant_system(:app_admin)
@@ -8,13 +8,24 @@ class SuperAuth::ActiveRecord::Authorization < ActiveRecord::Base
8
8
  from("(#{SuperAuth::Edge.authorizations.sql}) as super_auth_authorizations".squish)
9
9
  end
10
10
 
11
- # Clears and repopulates the authorizations table from the current graph.
11
+ # Clears and repopulates the authorizations table from the current graph
12
+ # with one INSERT ... SELECT on this connection, and returns the row
13
+ # count; see SuperAuth::Authorization.compile!. The guards run before the
14
+ # delete, so a refused compile leaves the previous rows in place.
12
15
  def compile!
13
16
  transaction do
17
+ # Sequel runs on this transaction's connection (sequel-activerecord_connection),
18
+ # so the JIT switch lands in it; see SuperAuth::Authorization.compile!.
19
+ SuperAuth.db.run "SET LOCAL jit = off" if SuperAuth.db.database_type == :postgres
20
+ SuperAuth::Authorization.assert_compilable!
14
21
  delete_all
15
- from_graph.each { |auth| create!(auth.attributes.except("id")) }
22
+ connection.execute(
23
+ SuperAuth.db[:super_auth_authorizations].insert_sql(
24
+ SuperAuth::Edge::AUTHORIZATION_COLUMNS, SuperAuth::Authorization.compile_source
25
+ )
26
+ )
27
+ count
16
28
  end
17
- count
18
29
  end
19
30
  end
20
31
  end
@@ -19,44 +19,151 @@ module SuperAuth::ActiveRecord::ByCurrentUser
19
19
  # "Resource::ResourceRestartPermission" (edges to a SuperAuth::Resource
20
20
  # registered with that external_type). If you can't load the object, you
21
21
  # can't call the method.
22
+ #
23
+ # A parent step admits a row through a column holding another record's id,
24
+ # the row's tenancy read off the row itself, so a grant on the organization
25
+ # reaches every claim whose organization_id it is without a node per claim:
26
+ #
27
+ # class Claim < ApplicationRecord
28
+ # super_auth parent: { column: :organization_id,
29
+ # resource_type: %w[Organization::Member Organization::Admin] }
30
+ # end
31
+ #
32
+ # The steps are OR'd, never collapsed into the parent step alone: a
33
+ # per-record grant admits a row whose parent column is NULL, and a parent
34
+ # grant admits rows that have no node. A subclass inherits the declared
35
+ # parents and is still keyed on its own name; re-declaring on the subclass
36
+ # replaces its parents alone, on the one inherited default scope, since two
37
+ # default scopes AND together and would deny every row the parent step
38
+ # admits.
22
39
  def self.included(base)
23
- base.send(:default_scope, **{all_queries: true}) do
40
+ # The attributes mark a hierarchy that already carries the scope. A
41
+ # second include, a subclass re-declaring or a host including the module
42
+ # twice, would add a second default scope.
43
+ return if base.respond_to?(:super_auth_reach)
44
+
45
+ base.class_attribute :super_auth_reach, instance_writer: false
46
+ base.class_attribute :super_auth_wildcard, instance_writer: false, default: true
47
+ base.extend ClassMethods
48
+ base.send(:default_scope, all_queries: true) do
24
49
  if SuperAuth.current_user.blank?
25
50
  raise SuperAuth::Error, "SuperAuth.current_user not set" if SuperAuth.missing_user_behavior == :raise
26
51
  next none
27
52
  end
53
+ next self if SuperAuth.current_user.respond_to?(:system?) && SuperAuth.current_user.system?
28
54
 
29
- if SuperAuth.current_user.respond_to?(:system?) && SuperAuth.current_user.system?
30
- self
31
- else
32
- user_where =
33
- if SuperAuth.internal_user?(SuperAuth.current_user)
34
- { user_id: SuperAuth.current_user.id }
35
- else
36
- { user_external_id: SuperAuth.current_user.id, user_external_type: SuperAuth.current_user.class.name }
37
- end
55
+ model.super_auth_preflight!
56
+ held = SuperAuth::ActiveRecord::ByCurrentUser.held_by(SuperAuth.current_user)
57
+
58
+ # Type-level authorization (resource_external_id IS NULL) acts as wildcard:
59
+ # user has access to ALL records of this type (e.g., admin with ADMIN_ACCESS).
60
+ if model.super_auth_wildcard && held.where(resource_external_type: model.name, resource_external_id: nil).exists?
61
+ next self
62
+ end
63
+
64
+ # One IN-subquery per step of the reach, OR'd: the row's own id against
65
+ # the class's own type, then each parent column against its types. No
66
+ # type handling here: the external id columns are created with the
67
+ # app's pk type (SuperAuth.external_id_type at install time), so the
68
+ # comparison is natively typed. all_queries, so an instance's update,
69
+ # destroy and reload carry the same OR.
70
+ model.super_auth_effective_reach.map do |column, types|
71
+ where(column => held.where(resource_external_type: types).where.not(resource_external_id: nil).select(:resource_external_id))
72
+ end.reduce(:or)
73
+ end
74
+ end
75
+
76
+ # The compiled rows `user` holds, matched the way compile! wrote them: a
77
+ # SuperAuth user by user_id, an application user by its id and class name.
78
+ def self.held_by(user)
79
+ if SuperAuth.internal_user?(user)
80
+ SuperAuth::ActiveRecord::Authorization.where(user_id: user.id)
81
+ else
82
+ SuperAuth::ActiveRecord::Authorization.where(user_external_id: user.id, user_external_type: user.class.name)
83
+ end
84
+ end
85
+
86
+ # ActiveRecord already folds the widths of one storage class into one
87
+ # abstract type (integer and bigint, varchar and char); text joins varchar
88
+ # here because the database compares those two natively.
89
+ def self.type_family(column)
90
+ column.type == :text ? :string : column.type
91
+ end
38
92
 
39
- resource_type = self.model.name
40
-
41
- # Type-level authorization (resource_external_id IS NULL) acts as wildcard:
42
- # user has access to ALL records of this type (e.g., admin with ADMIN_ACCESS).
43
- type_level = SuperAuth::ActiveRecord::Authorization
44
- .where(**user_where, resource_external_type: resource_type, resource_external_id: nil)
45
-
46
- if type_level.exists?
47
- self
48
- else
49
- # Per-record authorization: filter to specific records the user can
50
- # access. No type handling here: the external id columns are created
51
- # with the app's pk type (SuperAuth.external_id_type at install
52
- # time), so the comparison is natively typed.
53
- where(
54
- id: SuperAuth::ActiveRecord::Authorization
55
- .where(**user_where, resource_external_type: resource_type)
56
- .where.not(resource_external_id: nil)
57
- .select(:resource_external_id))
93
+ module ClassMethods
94
+ # The reach as this class queries it: the per-record step on its own
95
+ # name, since a subclass is its own resource type, then the parent steps
96
+ # it or its nearest declaring ancestor declared. Recomputed rather than
97
+ # stored because the stored map's :id names the declaring class.
98
+ def super_auth_effective_reach
99
+ { id: [name] }.merge(super_auth_parents)
100
+ end
101
+
102
+ # The compiled rows admitting one row for the current user, each tagged
103
+ # with the step it came through (:type_level, :id or the parent column),
104
+ # the rows the scope's subqueries match; the compiled table alone no
105
+ # longer answers who can see a row once a parent column takes part.
106
+ # Empty when nothing admits it; [{ step: :system }] under the system
107
+ # user, which bypasses the compiled table. The row is read unscoped,
108
+ # since the question is usually asked about one the user cannot see.
109
+ def super_auth_explain(record_or_id)
110
+ user = SuperAuth.current_user
111
+ if user.blank?
112
+ raise SuperAuth::Error, "SuperAuth.current_user not set" if SuperAuth.missing_user_behavior == :raise
113
+ return []
114
+ end
115
+ return [{ step: :system }] if user.respond_to?(:system?) && user.system?
116
+
117
+ record = unscoped.find(record_or_id.is_a?(::ActiveRecord::Base) ? record_or_id.id : record_or_id)
118
+ held = SuperAuth::ActiveRecord::ByCurrentUser.held_by(user)
119
+ tag = ->(step, rows) { rows.map { |row| { step: step, **row.attributes.symbolize_keys } } }
120
+
121
+ rows = []
122
+ rows.concat tag.(:type_level, held.where(resource_external_type: name, resource_external_id: nil)) if super_auth_wildcard
123
+ super_auth_effective_reach.each do |column, types|
124
+ value = record[column]
125
+ # A NULL column is reached by nothing: "col = NULL" is never true.
126
+ next if value.nil?
127
+ rows.concat tag.(column, held.where(resource_external_type: types, resource_external_id: value))
128
+ end
129
+ rows
130
+ end
131
+
132
+ # Each parent column must exist on the table and share
133
+ # resource_external_id's type family. Checked on the first query rather
134
+ # than at declaration so a process can boot before its migrations run,
135
+ # and once per model, since the answer changes only with the schema.
136
+ # Postgres refuses a mismatched comparison; MySQL coerces it silently and
137
+ # admits whatever rows the cast happens to match, so the declaration is
138
+ # refused here, naming both sides.
139
+ def super_auth_preflight!
140
+ return if @super_auth_preflight
141
+
142
+ expected = SuperAuth::ActiveRecord::Authorization.columns_hash.fetch("resource_external_id")
143
+ super_auth_parents.each_key do |column|
144
+ actual = columns_hash[column.to_s]
145
+ unless actual
146
+ raise SuperAuth::Error, "#{name} declares parent column #{column}, which table #{table_name} does not have"
147
+ end
148
+ unless SuperAuth::ActiveRecord::ByCurrentUser.type_family(actual) == SuperAuth::ActiveRecord::ByCurrentUser.type_family(expected)
149
+ raise SuperAuth::Error, "#{name}.#{column} is #{actual.sql_type} but super_auth_authorizations.resource_external_id is #{expected.sql_type}; " \
150
+ "a parent column must have the type of SuperAuth.external_id_type, the type of the ids it holds"
58
151
  end
59
152
  end
153
+ @super_auth_preflight = true
154
+ end
155
+
156
+ # The preflight's answer is column information, so it is dropped with it.
157
+ def reset_column_information
158
+ @super_auth_preflight = nil
159
+ super
160
+ end
161
+
162
+ private
163
+
164
+ # No macro call (the module included directly) declares no parents.
165
+ def super_auth_parents
166
+ super_auth_reach ? SuperAuth::Reach.parents(super_auth_reach) : {}
60
167
  end
61
168
  end
62
169
  end
@@ -1,5 +1,8 @@
1
+ require_relative "nested"
2
+
1
3
  class SuperAuth::ActiveRecord::Group < ActiveRecord::Base
2
4
  self.table_name = 'super_auth_groups'
5
+ include SuperAuth::ActiveRecord::Nested
3
6
 
4
7
  belongs_to :parent, class_name: 'SuperAuth::ActiveRecord::Group', optional: true
5
8
 
@@ -0,0 +1,43 @@
1
+ # The tree safety SuperAuth::Nestable gives the Sequel models, for the three
2
+ # ActiveRecord twins that nest: a node may not be its own parent nor sit
3
+ # under one of its own descendants (either closes a parent_id cycle, and a
4
+ # cycle silently makes every node in it an ancestor of every other), and
5
+ # destroying a node takes its compiled rows and its edges with it. The
6
+ # ancestor walk runs through the Sequel twin on this connection
7
+ # (sequel-activerecord_connection), so it sees the open transaction. Children
8
+ # of a destroyed node are not touched: the foreign key refuses to orphan
9
+ # them, and re-rooting or deleting them is the caller's decision.
10
+ module SuperAuth::ActiveRecord::Nested
11
+ def self.included(base)
12
+ base.validate :parent_outside_own_subtree
13
+ base.before_destroy :purge_grants
14
+ end
15
+
16
+ private
17
+
18
+ # SuperAuth::ActiveRecord::Group -> SuperAuth::Group. Through base_class,
19
+ # because a host subclasses these models to add scopes and callbacks and
20
+ # the twin is the gem's: on the concrete name, Module#const_get falls
21
+ # through to Object and returns the host's own class, which has neither
22
+ # ancestor_pairs nor singularize, so every re-parent and every destroy
23
+ # through a subclass raised NoMethodError.
24
+ def sequel_twin
25
+ SuperAuth.const_get(self.class.base_class.name.split("::").last)
26
+ end
27
+
28
+ def parent_outside_own_subtree
29
+ return if parent_id.nil? || !will_save_change_to_attribute?(:parent_id)
30
+
31
+ if parent_id == id
32
+ errors.add(:parent_id, "cannot be the node itself")
33
+ elsif persisted? && sequel_twin.ancestor_pairs(of: [parent_id]).where(ancestor_id: id).count > 0
34
+ errors.add(:parent_id, "is inside the node's own subtree, which would close a cycle")
35
+ end
36
+ end
37
+
38
+ def purge_grants
39
+ column = :"#{sequel_twin.singularize}_id"
40
+ SuperAuth::ActiveRecord::Authorization.where(column => id).delete_all
41
+ SuperAuth::ActiveRecord::Edge.where(column => id).delete_all
42
+ end
43
+ end
@@ -1,6 +1,13 @@
1
+ require_relative "nested"
2
+
1
3
  class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
2
4
  self.table_name = 'super_auth_resources'
5
+ include SuperAuth::ActiveRecord::Nested
3
6
  belongs_to :external, polymorphic: true, optional: true
7
+ # optional: is load-bearing: Rails hosts set belongs_to_required_by_default,
8
+ # the gem's own suite does not, so a missing one passes CI and fails the
9
+ # host on every root node.
10
+ belongs_to :parent, class_name: 'SuperAuth::ActiveRecord::Resource', optional: true
4
11
 
5
12
  # `super_auth_label` is a stored snapshot of the application record's human
6
13
  # name, so the editor can render "Gulf War presumptive" instead of
@@ -14,6 +21,23 @@ class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
14
21
  # write this row.
15
22
  before_save :set_label, if: :external_id?
16
23
 
24
+ # Type-level nodes that admit nobody: their external_type names no loaded
25
+ # ActiveRecord class that carries the ByCurrentUser scope — the class does
26
+ # not exist, or the scope sits only on a nested subclass (User with the
27
+ # scope on User::Directory and User::Writable) — so every row compiled from
28
+ # them matches no model's query and nothing notices. Hosts accumulate them
29
+ # by moving a model to the readonly-base-plus-Writable-subclass pattern and
30
+ # never pruning the type list that mints the nodes; the first consumer found
31
+ # seven. Rails-side by necessity: only a process with the models loaded can
32
+ # say what a type string resolves to, which is why this is not a bucket of
33
+ # SuperAuth::RLS.coverage.
34
+ def self.dead_type_level_nodes
35
+ where(external_id: nil).where.not(external_type: nil).reject do |node|
36
+ klass = node.external_type.safe_constantize
37
+ klass.is_a?(Class) && klass < ::ActiveRecord::Base && klass.include?(SuperAuth::ActiveRecord::ByCurrentUser)
38
+ end
39
+ end
40
+
17
41
  # Re-derive the label after the application record is renamed. Hosts call it
18
42
  # from whatever already syncs the node; super_auth:labels:backfill calls it
19
43
  # for every row.
@@ -33,10 +57,10 @@ class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
33
57
  # path, and never fails the save. Three things derive nil and none of them
34
58
  # means "this record has no name": RLS makes the application record
35
59
  # unreadable without an asserted identity, external_type is a plain string
36
- # that can name a class this process has not loaded, and type-level rows
37
- # (external_id IS NULL) have no record to name at all. Writing nil for any
38
- # of them would turn "this label is stale" into data, which is the failure
39
- # this column exists to avoid.
60
+ # that can name a class this process has not loaded, and id-less rows — a
61
+ # container, or a type-level node — have no record to name at all.
62
+ # Writing nil for any of them would turn "this label is stale" into data,
63
+ # which is the failure this column exists to avoid.
40
64
  def derived_label
41
65
  SuperAuth.label_for(external)
42
66
  rescue NameError
@@ -1,5 +1,8 @@
1
+ require_relative "nested"
2
+
1
3
  class SuperAuth::ActiveRecord::Role < ActiveRecord::Base
2
4
  self.table_name = 'super_auth_roles'
5
+ include SuperAuth::ActiveRecord::Nested
3
6
 
4
7
  belongs_to :parent, class_name: 'SuperAuth::ActiveRecord::Role', optional: true
5
8
 
@@ -4,8 +4,36 @@ end
4
4
 
5
5
  class ActiveRecord::Base
6
6
  class << self
7
- def super_auth
8
- include SuperAuth::ActiveRecord::ByCurrentUser
7
+ # Filter this model through the ByCurrentUser scope, keyed on the class's
8
+ # own name. parent: names the columns through which a grant on another
9
+ # record reaches a row — the row's tenancy, read off the row itself —
10
+ # each with the types whose rows admit through it: a Hash
11
+ # {column:, resource_type: String|[String]} or an Array of them, the
12
+ # shape SuperAuth::Reach normalises and SuperAuth::RLS.enable takes under
13
+ # the same keyword, so the same arguments produce the same map in both
14
+ # layers; whether the arguments agree is what RLS.current? checks.
15
+ # wildcard: false drops the type-level step (a row with
16
+ # resource_external_id NULL admitting every record of the type), which
17
+ # is otherwise always emitted. Omitting it keeps whatever the class
18
+ # already has — the default on a first declaration, the base's value on a
19
+ # subclass — because a subclass usually re-declares to replace its
20
+ # parents, and taking the keyword's default there would silently hand
21
+ # back the type-level step a base opted out of, against a policy built
22
+ # without it. Say wildcard: true to put it back. Only true and false are
23
+ # accepted, with the message RLS.enable gives: nil there means "drop the
24
+ # step" to the scope and "leave it alone" here, and a truthy string keeps
25
+ # it, so neither may pass silently. The reach is validated here and the
26
+ # table is not read: the columns are checked on the first query, so a
27
+ # process can boot before its migrations run. On a subclass the call
28
+ # replaces the parents for that subclass alone and adds no second scope.
29
+ def super_auth(parent: nil, wildcard: nil)
30
+ unless [true, false, nil].include?(wildcard)
31
+ raise SuperAuth::Error, "wildcard: must be true or false, got #{wildcard.inspect}"
32
+ end
33
+ reach = SuperAuth::Reach.normalize(resource_type: name, parent: parent)
34
+ include SuperAuth::ActiveRecord::ByCurrentUser unless include?(SuperAuth::ActiveRecord::ByCurrentUser)
35
+ self.super_auth_reach = reach
36
+ self.super_auth_wildcard = wildcard unless wildcard.nil?
9
37
  end
10
38
  end
11
39
  end
@@ -1,14 +1,67 @@
1
1
  class SuperAuth::Authorization < Sequel::Model(:super_auth_authorizations)
2
- # Clears and repopulates the compiled table from the current graph, inside
3
- # one transaction, and returns the row count. Row by row, like
4
- # SuperAuth::ActiveRecord::Authorization.compile!; a single INSERT ... SELECT
5
- # is a separate change. Runtime enforcement (ByCurrentUser, the RLS policies)
6
- # reads only this table, so every edit to the graph is inert until this runs.
7
- def self.compile!
8
- db.transaction do
9
- dataset.delete
10
- SuperAuth::Edge.authorizations.each { |row| dataset.insert(row) }
11
- dataset.count
2
+ class << self
3
+ # Clears and repopulates the compiled table from the current graph, inside
4
+ # one transaction, and returns the row count. One INSERT ... SELECT of
5
+ # SuperAuth::Edge.authorizations, like the ActiveRecord twin: row by row
6
+ # the same graph loaded at ~570 rows/s through the model, which at a
7
+ # million rows is most of an hour in one held transaction, with every row
8
+ # resident in Ruby. Runtime enforcement (ByCurrentUser, the RLS policies)
9
+ # reads only this table, so every edit to the graph is inert until this
10
+ # runs. The guards run first, before the delete, so a refused compile
11
+ # leaves the previous rows in place rather than an empty table.
12
+ #
13
+ # Postgres JIT-compiles the union's expressions on every run: 539 LLVM
14
+ # functions, 1.6-2.2s of optimisation and emission for a query that then
15
+ # executes in milliseconds. SET LOCAL scopes the switch to this
16
+ # transaction, so nothing leaks to the pooled connection.
17
+ #
18
+ # Everything here runs on SuperAuth.db, named, not on this class's own
19
+ # `db`. A host loads the models at require time, before it has connected
20
+ # anything, so Sequel binds them to whatever Sequel::Model.db is then — a
21
+ # mock in a Rails boot — and SuperAuth.db= rebinds them afterwards; the
22
+ # first consumer's binding of this one class had been left behind for
23
+ # months and nothing noticed until a branch on `db.database_type` here
24
+ # skipped the timestamp cast and Postgres refused the INSERT.
25
+ def compile!
26
+ SuperAuth.db.transaction do
27
+ SuperAuth.db.run "SET LOCAL jit = off" if SuperAuth.db.database_type == :postgres
28
+ assert_compilable!
29
+ table = SuperAuth.db[:super_auth_authorizations]
30
+ table.delete
31
+ table.insert(SuperAuth::Edge::AUTHORIZATION_COLUMNS, compile_source)
32
+ table.count
33
+ end
34
+ end
35
+
36
+ # What must hold before the compiled table is touched, in the order the
37
+ # failures are worst. A parent_id cycle in any tree table first: the
38
+ # walks terminate on one, so it does not fail a compile — it quietly
39
+ # makes every node in the cycle an ancestor of every other, and a grant
40
+ # on any of them reaches all their subtrees. Then the resource tree's own
41
+ # rule, that type-level nodes are flat.
42
+ def assert_compilable!
43
+ SuperAuth::Group.assert_acyclic!
44
+ SuperAuth::Role.assert_acyclic!
45
+ SuperAuth::Resource.assert_acyclic!
46
+ SuperAuth::Resource.assert_compilable!
47
+ end
48
+
49
+ # The SELECT the compile inserts from. The eight timestamp columns travel
50
+ # through the union as text (the MySQL collation reason in
51
+ # SuperAuth::Edge.string_cast_type), and Postgres has no assignment cast
52
+ # from text to timestamp: inserting the bare union there fails with
53
+ # "column ... is of type timestamp ... but expression is of type text".
54
+ # They are cast back on Postgres only. MySQL converts on assignment, and
55
+ # SQLite's CAST(... AS timestamp) has NUMERIC affinity, which would keep
56
+ # the "2026" of a date and drop the rest.
57
+ def compile_source
58
+ graph = SuperAuth::Edge.authorizations
59
+ return graph unless SuperAuth.db.database_type == :postgres
60
+
61
+ columns = SuperAuth::Edge::AUTHORIZATION_COLUMNS.map do |column|
62
+ column.end_with?("_at") ? Sequel.cast(column, :timestamp).as(column) : column
63
+ end
64
+ graph.from_self(alias: :graph).select(*columns)
12
65
  end
13
66
  end
14
67
  end