super_auth 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -0
- data/Gemfile.lock +1 -1
- data/README.md +440 -42
- data/USAGE.md +127 -21
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate/12_add_resource_indexes.rb +116 -0
- data/db/migrate/13_add_resource_tree_guard.rb +28 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
- data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
- data/db/seeds/sample_data.rb +1 -0
- data/lib/generators/super_auth/install/templates/README +6 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +14 -3
- data/lib/super_auth/active_record/by_current_user.rb +136 -29
- data/lib/super_auth/active_record/group.rb +3 -0
- data/lib/super_auth/active_record/nested.rb +43 -0
- data/lib/super_auth/active_record/resource.rb +28 -4
- data/lib/super_auth/active_record/role.rb +3 -0
- data/lib/super_auth/active_record.rb +30 -2
- data/lib/super_auth/authorization.rb +63 -10
- data/lib/super_auth/edge.rb +73 -18
- data/lib/super_auth/editor/index.html +16 -10
- data/lib/super_auth/editor/seed.rb +11 -5
- data/lib/super_auth/editor.rb +35 -11
- data/lib/super_auth/nestable.rb +105 -4
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +71 -0
- data/lib/super_auth/rls.rb +576 -44
- data/lib/super_auth/tree_guard.rb +115 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +55 -2
- metadata +10 -1
data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb
ADDED
|
@@ -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
|
data/db/seeds/sample_data.rb
CHANGED
|
@@ -4,11 +4,15 @@ SuperAuth initializer created at config/initializers/super_auth.rb
|
|
|
4
4
|
|
|
5
5
|
Next steps:
|
|
6
6
|
|
|
7
|
-
1.
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
|
37
|
-
#
|
|
38
|
-
# of them would turn "this label is stale" into data,
|
|
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
|
|
@@ -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,14 +1,67 @@
|
|
|
1
1
|
class SuperAuth::Authorization < Sequel::Model(:super_auth_authorizations)
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|