super_auth 0.8.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/Gemfile.lock +1 -1
  4. data/README.md +409 -28
  5. data/USAGE.md +66 -25
  6. data/db/migrate/12_add_resource_indexes.rb +116 -0
  7. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  8. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  9. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  10. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  11. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  12. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  13. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  14. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  15. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  16. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  17. data/lib/generators/super_auth/install/templates/README +4 -2
  18. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  19. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  20. data/lib/super_auth/active_record/authorization.rb +11 -7
  21. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  22. data/lib/super_auth/active_record/group.rb +3 -0
  23. data/lib/super_auth/active_record/nested.rb +43 -0
  24. data/lib/super_auth/active_record/resource.rb +21 -1
  25. data/lib/super_auth/active_record/role.rb +3 -0
  26. data/lib/super_auth/active_record.rb +30 -2
  27. data/lib/super_auth/authorization.rb +62 -21
  28. data/lib/super_auth/edge.rb +26 -7
  29. data/lib/super_auth/editor/index.html +3 -4
  30. data/lib/super_auth/editor.rb +12 -3
  31. data/lib/super_auth/nestable.rb +89 -1
  32. data/lib/super_auth/railtie.rb +0 -9
  33. data/lib/super_auth/reach.rb +88 -0
  34. data/lib/super_auth/resource.rb +41 -27
  35. data/lib/super_auth/rls.rb +576 -44
  36. data/lib/super_auth/tree_guard.rb +115 -0
  37. data/lib/super_auth/version.rb +1 -1
  38. data/lib/super_auth.rb +55 -33
  39. metadata +8 -1
@@ -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,5 +1,8 @@
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
4
7
  # optional: is load-bearing: Rails hosts set belongs_to_required_by_default,
5
8
  # the gem's own suite does not, so a missing one passes CI and fails the
@@ -18,6 +21,23 @@ class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
18
21
  # write this row.
19
22
  before_save :set_label, if: :external_id?
20
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
+
21
41
  # Re-derive the label after the application record is renamed. Hosts call it
22
42
  # from whatever already syncs the node; super_auth:labels:backfill calls it
23
43
  # for every row.
@@ -38,7 +58,7 @@ class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
38
58
  # means "this record has no name": RLS makes the application record
39
59
  # unreadable without an asserted identity, external_type is a plain string
40
60
  # that can name a class this process has not loaded, and id-less rows — a
41
- # container, or a deprecated wildcard — have no record to name at all.
61
+ # container, or a type-level node — have no record to name at all.
42
62
  # Writing nil for any of them would turn "this label is stale" into data,
43
63
  # which is the failure this column exists to avoid.
44
64
  def derived_label
@@ -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,26 +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
- # The wildcard guard runs first, before the delete, so a refused compile
8
- # leaves the previous rows in place rather than an empty table; the
9
- # deprecation notice comes after the commit, for a compile that happened.
10
- #
11
- # Postgres JIT-compiles the union's expressions on every run: 539 LLVM
12
- # functions, 1.6-2.2s of optimisation and emission for a query that then
13
- # executes in milliseconds. SET LOCAL scopes the switch to this transaction,
14
- # so nothing leaks to the pooled connection.
15
- def self.compile!
16
- count = db.transaction do
17
- db.run "SET LOCAL jit = off" if db.database_type == :postgres
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!
18
46
  SuperAuth::Resource.assert_compilable!
19
- dataset.delete
20
- SuperAuth::Edge.authorizations.each { |row| dataset.insert(row) }
21
- dataset.count
22
47
  end
23
- SuperAuth::Resource.warn_deprecated_wildcards
24
- count
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)
65
+ end
25
66
  end
26
67
  end
@@ -7,6 +7,19 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
7
7
  many_to_one :role
8
8
  many_to_one :resource
9
9
 
10
+ # The columns of `authorizations`, in its order. compile! inserts the union
11
+ # straight into super_auth_authorizations under this list, so it is the
12
+ # contract between the five SELECTs below and the table: MySQL and Postgres
13
+ # both need the column list, and the table has columns the union does not
14
+ # fill (its own timestamps, the ActiveRecord migration's id).
15
+ AUTHORIZATION_COLUMNS = %i[
16
+ user_id user_name user_external_id user_external_type user_created_at user_updated_at
17
+ group_id group_name group_path group_name_path group_parent_id group_created_at group_updated_at
18
+ role_id role_name role_path role_name_path role_parent_id role_created_at role_updated_at
19
+ permission_id permission_name permission_created_at permission_updated_at
20
+ resource_id resource_name resource_external_id resource_external_type
21
+ ].freeze
22
+
10
23
  class << self
11
24
  # The five strategies are UNIONed positionally. A column that is a real
12
25
  # text column in one strategy and CAST(NULL AS ...) in another must be cast
@@ -47,7 +60,10 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
47
60
  # per strategy — 27s per strategy on MySQL at 300k resources, 0.025s
48
61
  # anchored — so the walk is sized by the grants, not by the table. On a
49
62
  # flat graph it is the identity relation and the compiled rows are exactly
50
- # what the previous pk join produced.
63
+ # what the previous pk join produced. The walk never descends from a
64
+ # type-level node (Resource.descend_from): a granted (type, NULL) node
65
+ # pairs with itself and nothing beneath it, on every path that reads this
66
+ # relation, compile! or not.
51
67
  def resource_subtrees
52
68
  granted = db[:super_auth_edges].exclude(resource_id: nil).select(:resource_id)
53
69
  SuperAuth::Resource.descendant_pairs(of: granted)
@@ -63,12 +79,15 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
63
79
  ds.
64
80
  join(resource_subtrees.as(:resource_descendants), ancestor_id: resource_id_column).
65
81
  join(Sequel[:super_auth_resources], id: Sequel[:resource_descendants][:descendant_id]).
66
- # A (type, NULL) row — a wildcard, every record of its type — is only
67
- # ever the node the grant named, never one reached through the tree.
68
- # Resource.assert_compilable! refuses that shape loudly, but it is a
69
- # separate statement from this one: under READ COMMITTED a write that
70
- # nests a wildcard can land between the two, and the compiled table
71
- # must not widen a container grant to a whole type because of it.
82
+ # A (type, NULL) row — a type-level node, every record of its type —
83
+ # is only ever the node the grant named, never one reached through
84
+ # the tree; the walk itself refuses the other direction, a node
85
+ # reached through a type-level parent. Resource.assert_compilable!
86
+ # refuses both shapes loudly, but it is a separate statement from this
87
+ # one: under READ COMMITTED a write that nests a type-level node can
88
+ # land between the two, and the compiled table must not widen a
89
+ # container grant to a whole type, or a type-level grant to the nodes
90
+ # under it, because of it.
72
91
  where(
73
92
  Sequel.|(
74
93
  { Sequel[:resource_descendants][:ancestor_id] => Sequel[:resource_descendants][:descendant_id] },
@@ -251,12 +251,11 @@ function render(){
251
251
  // The external record occupies one slot: its label when the graph
252
252
  // stored one, otherwise the Type#id that used to be the only
253
253
  // rendering. Either way the other half is the tooltip. A type with no
254
- // id is the deprecated type-level (wildcard) node — every record of
255
- // the type — and its tooltip says so, since Type#* alone reads as a
256
- // typo.
254
+ // id is the type-level (wildcard) node — every record of the type —
255
+ // and its tooltip says so, since Type#* alone reads as a typo.
257
256
  const wildcard = type === "resource" && !!n.external_type && n.external_id == null;
258
257
  const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
259
- const kind = wildcard ? "type-level (deprecated)" : "external record";
258
+ const kind = wildcard ? "type-level grant" : "external record";
260
259
  const ext = !ref ? "" : (n.super_auth_label
261
260
  ? `<span class="ext" title="${wildcard ? `${ref} · ${kind}` : ref}">${escapeHtml(n.super_auth_label)}</span>`
262
261
  : `<span class="ext" title="${kind}">${ref}</span>`);
@@ -171,7 +171,7 @@ module SuperAuth
171
171
  # "Wildcard nodes are flat": compile! refuses a tree with a type-level
172
172
  # node in it, so refuse the shape at the door with the reason instead.
173
173
  if type == "resource" && parent_node.external_type && parent_node.external_id.nil?
174
- return json(422, error: "type-level (wildcard) resources are deprecated and cannot contain other resources; " \
174
+ return json(422, error: "type-level (wildcard) resources are flat and cannot contain other resources; " \
175
175
  "make a container (a resource with no external type) instead")
176
176
  end
177
177
  attrs[:parent_id] = parent.to_i
@@ -187,8 +187,17 @@ module SuperAuth
187
187
 
188
188
  SuperAuth.db.transaction do
189
189
  SuperAuth::Edge.where(COLUMNS[type] => record.id).delete
190
- # Children become roots: the deny-safe choice, and required before the
191
- # delete on MySQL, which checks the self-referencing key row by row.
190
+ # The node's own compiled rows go with it: runtime reads only that
191
+ # table, and a row naming a node that no longer exists would keep
192
+ # granting until the next compile. Rows compiled through it for its
193
+ # descendants stay until one runs, as after any other revocation.
194
+ SuperAuth::Authorization.where(COLUMNS[type] => record.id).delete
195
+ # Children become roots, deliberately, and the client's confirm says
196
+ # so: not the grandparent's children, whose grants would then reach
197
+ # them, and not deleted with the node, which is not what "delete this
198
+ # container" asks. A root grants nothing by itself, so it is the
199
+ # deny-safe choice. Also required before the delete on MySQL, which
200
+ # checks the self-referencing key row by row.
192
201
  model.where(parent_id: record.id).update(parent_id: nil) if NESTED.include?(type)
193
202
  model.where(id: record.id).delete
194
203
  end
@@ -24,6 +24,44 @@ module SuperAuth::Nestable
24
24
  end
25
25
  end
26
26
 
27
+ # A node may not be its own parent, nor sit under one of its own
28
+ # descendants: either closes a parent_id cycle. The pair CTEs terminate on
29
+ # one (UNION), so a cycle does not hang a compile; it does something
30
+ # quieter and worse. Every node in a cycle is an ancestor of every other,
31
+ # so a grant on any of them reaches all of their subtrees — a container
32
+ # pointed at one of its own children turned a single per-record read into
33
+ # the container's whole membership, with nothing raised anywhere. Checked
34
+ # only when parent_id changes, by walking UP from the new parent: that is
35
+ # one row per level however large the subtree, and unlike the descendant
36
+ # walk it does not stop at a type-level node
37
+ # (SuperAuth::Resource.descend_from), so a cycle through one is caught too.
38
+ # assert_acyclic! covers writes that bypass the model.
39
+ def validate
40
+ super
41
+ return if parent_id.nil? || !changed_columns.include?(:parent_id)
42
+
43
+ if parent_id == id
44
+ errors.add(:parent_id, "cannot be the node itself")
45
+ elsif !new? && model.ancestor_pairs(of: [parent_id]).where(ancestor_id: id).count > 0
46
+ errors.add(:parent_id, "is inside the node's own subtree, which would close a cycle")
47
+ end
48
+ end
49
+
50
+ # A deleted node takes its compiled rows and its edges with it, in the
51
+ # transaction that deletes the row: runtime reads only the compiled table,
52
+ # and a row naming a node that no longer exists would keep granting until
53
+ # the next compile. Children are not touched. The foreign key refuses to
54
+ # orphan them, and whether they are re-rooted or deleted is the caller's
55
+ # decision (the editor re-roots them, deliberately). Rows compiled through
56
+ # this node for its descendants stay until the next compile, as after any
57
+ # other revocation.
58
+ def before_destroy
59
+ super
60
+ column = :"#{model.singularize}_id"
61
+ SuperAuth::Authorization.where(column => id).delete
62
+ SuperAuth::Edge.where(column => id).delete
63
+ end
64
+
27
65
  module ClassMethods
28
66
  # Helper method to get the appropriate string cast type for the database
29
67
  def string_cast_type
@@ -59,10 +97,14 @@ module SuperAuth::Nestable
59
97
  # produces nothing new, which on a parent_id cycle is the first time round;
60
98
  # UNION ALL re-derives the same pairs forever and compile! never returns.
61
99
  # On a valid tree no step repeats a pair, so the output is the same.
62
- def ancestor_pairs
100
+ #
101
+ # `of:` (a dataset or an array of ids) restricts the anchor to those
102
+ # nodes, so only their ancestor chains are walked: one row per level.
103
+ def ancestor_pairs(of: nil)
63
104
  table = pluralize
64
105
  name = :"#{singularize}_ancestor_pairs"
65
106
  anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
107
+ anchor = anchor.where(id: of) unless of.nil?
66
108
  step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
67
109
  select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
68
110
  db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
@@ -76,16 +118,62 @@ module SuperAuth::Nestable
76
118
  # whole table is cheap; resources are one row per protected record, and an
77
119
  # unanchored CTE materialises every pair of the whole table once per
78
120
  # strategy that joins it.
121
+ #
122
+ # The recursive step joins the parent row only when the model puts a
123
+ # condition on it (descend_from); the pair CTE itself never carries more
124
+ # than the two ids.
79
125
  def descendant_pairs(of: nil)
80
126
  table = pluralize
81
127
  name = :"#{singularize}_descendant_pairs"
128
+ parent = :"#{singularize}_parent"
82
129
  anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
83
130
  anchor = anchor.where(id: of) unless of.nil?
84
131
  step = db[name].join(table, parent_id: :descendant_id).
85
132
  select(Sequel[name][:ancestor_id], Sequel[table][:id])
133
+ if (condition = descend_from(parent))
134
+ step = step.join(Sequel[table].as(parent), id: Sequel[name][:descendant_id]).where(condition)
135
+ end
86
136
  db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
87
137
  end
88
138
 
139
+ # Whether descendant_pairs continues below a node: a Sequel condition on
140
+ # the parent row, addressed through the alias `parent`, or nil to descend
141
+ # from every node. Groups and roles descend from everything.
142
+ # SuperAuth::Resource stops at a type-level node, so a grant on one yields
143
+ # its own row and nothing beneath it on every path that reads the walk.
144
+ def descend_from(parent)
145
+ nil
146
+ end
147
+
148
+ # Every node some root reaches, walking parent_id downward from the rows
149
+ # that have none. On a valid forest that is the whole table; what it
150
+ # misses is exactly the nodes on or under a parent_id cycle (and, where
151
+ # no foreign key stands, a node whose parent is missing). No path
152
+ # columns, unlike trees: this runs over the whole resources table before
153
+ # every compile, and needs only the ids.
154
+ def rooted
155
+ table = pluralize
156
+ name = :"rooted_#{table}"
157
+ anchor = db[table].where(parent_id: nil).select(:id)
158
+ step = db[name].join(table, parent_id: :id).select(Sequel[table][:id])
159
+ db.from(name).with_recursive(name, anchor, step, args: [:id], union_all: false)
160
+ end
161
+
162
+ # Refuses a table with a parent_id cycle in it, naming the nodes no root
163
+ # reaches. compile! calls this for groups, roles and resources before
164
+ # touching the compiled table, because a cycle does not fail a compile:
165
+ # the walks terminate, and every node in the cycle is an ancestor of
166
+ # every other, so a grant on any of them silently reaches all of their
167
+ # subtrees. validate refuses the shape at the model; this catches it
168
+ # after a write that went around the model.
169
+ def assert_acyclic!
170
+ unreachable = dataset.exclude(id: rooted).select_order_map(:id)
171
+ return if unreachable.empty?
172
+
173
+ raise SuperAuth::Error, "#{pluralize} has a parent_id cycle: node(s) #{unreachable.join(', ')} " \
174
+ "cannot be reached from any root. Point one of them at a root, or at no parent, and recompile."
175
+ end
176
+
89
177
  def cte(id = nil, direction = :desc)
90
178
  model = self
91
179
  cte_name = model.cte_name