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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -1
- data/Gemfile.lock +1 -1
- data/README.md +409 -28
- data/USAGE.md +66 -25
- data/db/migrate/12_add_resource_indexes.rb +116 -0
- data/db/migrate/13_add_resource_tree_guard.rb +28 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
- data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
- data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
- data/lib/generators/super_auth/install/templates/README +4 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +11 -7
- data/lib/super_auth/active_record/by_current_user.rb +136 -29
- data/lib/super_auth/active_record/group.rb +3 -0
- data/lib/super_auth/active_record/nested.rb +43 -0
- data/lib/super_auth/active_record/resource.rb +21 -1
- data/lib/super_auth/active_record/role.rb +3 -0
- data/lib/super_auth/active_record.rb +30 -2
- data/lib/super_auth/authorization.rb +62 -21
- data/lib/super_auth/edge.rb +26 -7
- data/lib/super_auth/editor/index.html +3 -4
- data/lib/super_auth/editor.rb +12 -3
- data/lib/super_auth/nestable.rb +89 -1
- data/lib/super_auth/railtie.rb +0 -9
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +41 -27
- data/lib/super_auth/rls.rb +576 -44
- data/lib/super_auth/tree_guard.rb +115 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +55 -33
- 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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
@@ -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
|
|
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
|
|
@@ -4,8 +4,36 @@ end
|
|
|
4
4
|
|
|
5
5
|
class ActiveRecord::Base
|
|
6
6
|
class << self
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
data/lib/super_auth/edge.rb
CHANGED
|
@@ -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
|
|
67
|
-
# ever the node the grant named, never one reached through
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
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
|
|
255
|
-
//
|
|
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
|
|
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>`);
|
data/lib/super_auth/editor.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
191
|
-
#
|
|
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
|
data/lib/super_auth/nestable.rb
CHANGED
|
@@ -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
|
-
|
|
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
|