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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
module SuperAuth
|
|
2
|
+
# The database-side cycle guard on super_auth_resources.parent_id: a
|
|
3
|
+
# Postgres trigger refusing a node as its own parent, or a parent inside
|
|
4
|
+
# the node's own subtree. A parent_id cycle is a security defect once
|
|
5
|
+
# resources carry tenancy — every node in a cycle is an ancestor of every
|
|
6
|
+
# other, so a grant on any of them reaches all their subtrees, and the
|
|
7
|
+
# walks terminate on a cycle (UNION), so nothing fails loudly. The models
|
|
8
|
+
# refuse the shape and compile! refuses to run on it; the trigger is the
|
|
9
|
+
# line for writes that go around both: a raw UPDATE, a data migration,
|
|
10
|
+
# another language.
|
|
11
|
+
#
|
|
12
|
+
# Migration 13 installs it, and this module exists because a migration is
|
|
13
|
+
# not enough to deliver a trigger to where hosts test: Rails' default
|
|
14
|
+
# schema_format is :ruby, db/schema.rb cannot carry a function or a
|
|
15
|
+
# trigger, and db:test:prepare loads schema.rb — so on such a host the
|
|
16
|
+
# guard is present in development and production and absent from the test
|
|
17
|
+
# database, where a test asserting "cycles cannot happen" passes for the
|
|
18
|
+
# wrong reason. Call `install` from the test setup, beside
|
|
19
|
+
# SuperAuth::RLS.enable, which schema.rb loses for the same reason.
|
|
20
|
+
#
|
|
21
|
+
# Postgres only. SQLite and MySQL each have triggers in a dialect of their
|
|
22
|
+
# own; the model and compile guards hold there, and a second and third
|
|
23
|
+
# implementation is not worth carrying. On those, every method is a no-op
|
|
24
|
+
# that answers false.
|
|
25
|
+
module TreeGuard
|
|
26
|
+
NAME = "super_auth_resources_tree_guard".freeze
|
|
27
|
+
|
|
28
|
+
FUNCTION_SQL = <<~SQL.freeze
|
|
29
|
+
CREATE OR REPLACE FUNCTION #{NAME}() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
30
|
+
BEGIN
|
|
31
|
+
IF NEW.parent_id = NEW.id THEN
|
|
32
|
+
RAISE EXCEPTION 'super_auth_resources: node % cannot be its own parent', NEW.id
|
|
33
|
+
USING ERRCODE = 'check_violation';
|
|
34
|
+
END IF;
|
|
35
|
+
IF EXISTS (
|
|
36
|
+
WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
37
|
+
SELECT r.id, r.parent_id FROM super_auth_resources r WHERE r.id = NEW.parent_id
|
|
38
|
+
UNION
|
|
39
|
+
SELECT r.id, r.parent_id FROM super_auth_resources r JOIN ancestors a ON r.id = a.parent_id
|
|
40
|
+
)
|
|
41
|
+
SELECT 1 FROM ancestors WHERE ancestors.id = NEW.id OR ancestors.parent_id = NEW.id
|
|
42
|
+
) THEN
|
|
43
|
+
RAISE EXCEPTION 'super_auth_resources: parent_id % is inside the subtree of node %, which would close a cycle', NEW.parent_id, NEW.id
|
|
44
|
+
USING ERRCODE = 'check_violation';
|
|
45
|
+
END IF;
|
|
46
|
+
RETURN NEW;
|
|
47
|
+
END
|
|
48
|
+
$$;
|
|
49
|
+
SQL
|
|
50
|
+
|
|
51
|
+
# The walk goes UP from the new parent with UNION, so a cycle already in
|
|
52
|
+
# the table that does not include the row terminates instead of looping.
|
|
53
|
+
# It refuses an ancestor that IS the row and an ancestor whose parent_id
|
|
54
|
+
# NAMES the row, because migration 11 makes the parent_id key DEFERRABLE
|
|
55
|
+
# on Postgres: inside one transaction a row may point at a parent that
|
|
56
|
+
# does not exist yet, and on the id alone the walk stops on that dangling
|
|
57
|
+
# pointer and admits the write that closes the cycle when the parent
|
|
58
|
+
# arrives. A pointer at the row is a cycle whether or not its target
|
|
59
|
+
# exists yet, which is what the second test asks; an out-of-order insert
|
|
60
|
+
# with no cycle still passes.
|
|
61
|
+
# WHEN keeps the trigger off every root write, which is most of them.
|
|
62
|
+
TRIGGER_SQL = <<~SQL.freeze
|
|
63
|
+
CREATE TRIGGER #{NAME}
|
|
64
|
+
BEFORE INSERT OR UPDATE OF parent_id ON super_auth_resources
|
|
65
|
+
FOR EACH ROW WHEN (NEW.parent_id IS NOT NULL)
|
|
66
|
+
EXECUTE FUNCTION #{NAME}()
|
|
67
|
+
SQL
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Installs, or reinstalls, the function and the trigger. Idempotent;
|
|
71
|
+
# true when installed, false where the database has no such thing.
|
|
72
|
+
def install(db: SuperAuth.db)
|
|
73
|
+
return false unless postgres?(db)
|
|
74
|
+
|
|
75
|
+
db.run FUNCTION_SQL
|
|
76
|
+
db.run "DROP TRIGGER IF EXISTS #{NAME} ON super_auth_resources"
|
|
77
|
+
db.run TRIGGER_SQL
|
|
78
|
+
true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def remove(db: SuperAuth.db)
|
|
82
|
+
return false unless postgres?(db)
|
|
83
|
+
|
|
84
|
+
db.run "DROP TRIGGER IF EXISTS #{NAME} ON super_auth_resources"
|
|
85
|
+
db.run "DROP FUNCTION IF EXISTS #{NAME}()"
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Whether the trigger is on the table now — the question a test helper
|
|
90
|
+
# or a health check asks of a database that may have been built from
|
|
91
|
+
# schema.rb. The table is resolved through search_path, the way the
|
|
92
|
+
# CREATE TRIGGER above resolves it: matching pg_class.relname instead
|
|
93
|
+
# would answer for a super_auth_resources in any schema, so a leftover
|
|
94
|
+
# copy in another one that still carries the trigger would report the
|
|
95
|
+
# live table guarded when it is not. to_regclass rather than a
|
|
96
|
+
# ::regclass cast, as everywhere else in the gem, because this is asked
|
|
97
|
+
# of a database that may not have the table yet and a cast raises where
|
|
98
|
+
# the question has an answer: no table, no trigger.
|
|
99
|
+
def installed?(db: SuperAuth.db)
|
|
100
|
+
return false unless postgres?(db)
|
|
101
|
+
|
|
102
|
+
db.fetch(
|
|
103
|
+
"SELECT 1 FROM pg_trigger t WHERE t.tgrelid = to_regclass('super_auth_resources') " \
|
|
104
|
+
"AND t.tgname = ? AND NOT t.tgisinternal", NAME
|
|
105
|
+
).any?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def postgres?(db)
|
|
111
|
+
db.database_type == :postgres
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/super_auth/version.rb
CHANGED
data/lib/super_auth.rb
CHANGED
|
@@ -1,44 +1,15 @@
|
|
|
1
1
|
require_relative "super_auth/version"
|
|
2
|
+
require_relative "super_auth/reach"
|
|
3
|
+
require_relative "super_auth/tree_guard"
|
|
2
4
|
require "sequel"
|
|
3
5
|
|
|
4
6
|
module SuperAuth
|
|
5
7
|
class Error < StandardError; end
|
|
6
8
|
|
|
7
|
-
# Stand-in for ActiveSupport::Deprecation when ActiveSupport is not loaded:
|
|
8
|
-
# the same two methods the gem calls. Plain Kernel.warn rather than
|
|
9
|
-
# `category: :deprecated`, which Ruby hides unless -W:deprecated is set,
|
|
10
|
-
# and a warning nobody sees is not a deprecation.
|
|
11
|
-
class Deprecator
|
|
12
|
-
attr_accessor :silenced
|
|
13
|
-
|
|
14
|
-
def warn(message)
|
|
15
|
-
return if silenced
|
|
16
|
-
Kernel.warn "DEPRECATION WARNING: #{message}"
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
9
|
def self.setup
|
|
21
10
|
yield self if block_given?
|
|
22
11
|
end
|
|
23
12
|
|
|
24
|
-
# Where the gem's deprecation warnings go. An ActiveSupport::Deprecation
|
|
25
|
-
# when ActiveSupport is loaded, so a Rails host's
|
|
26
|
-
# config.active_support.deprecation applies once the railtie registers it
|
|
27
|
-
# under app.deprecators; otherwise the stand-in above. Both answer
|
|
28
|
-
# `silenced = true`. Memoized, so that setting survives.
|
|
29
|
-
def self.deprecator
|
|
30
|
-
@deprecator ||=
|
|
31
|
-
if defined?(ActiveSupport::Deprecation)
|
|
32
|
-
ActiveSupport::Deprecation.new("1.0", "SuperAuth")
|
|
33
|
-
else
|
|
34
|
-
Deprecator.new
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def self.deprecator=(deprecator)
|
|
39
|
-
@deprecator = deprecator
|
|
40
|
-
end
|
|
41
|
-
|
|
42
13
|
# Controls behavior when SuperAuth.current_user is blank in ByCurrentUser scope.
|
|
43
14
|
# :none (default) — returns an empty result set silently
|
|
44
15
|
# :raise — raises SuperAuth::Error
|
|
@@ -114,7 +85,13 @@ module SuperAuth
|
|
|
114
85
|
|
|
115
86
|
# Both ORMs cache column types per model class; after (re)installing the
|
|
116
87
|
# migrations those caches can describe a previous schema (e.g. a different
|
|
117
|
-
# external_id_type) and silently miscast assigned values.
|
|
88
|
+
# external_id_type) and silently miscast assigned values. The Sequel models
|
|
89
|
+
# are also rebound to SuperAuth.db: a host requires them before it has
|
|
90
|
+
# connected anything, so Sequel binds them to whatever Sequel::Model.db is
|
|
91
|
+
# at that moment (a mock, in a Rails boot), and a class left on that
|
|
92
|
+
# binding answers `db.database_type` wrong and runs its queries nowhere.
|
|
93
|
+
# Rebinding here, and from SuperAuth.db=, keeps Model.db equal to
|
|
94
|
+
# SuperAuth.db for every model, so no host needs to do it by hand.
|
|
118
95
|
def self.refresh_model_schemas
|
|
119
96
|
models = %w[User Group Permission Role Resource Edge Authorization]
|
|
120
97
|
if defined?(SuperAuth::ActiveRecord::User)
|
|
@@ -125,7 +102,20 @@ module SuperAuth
|
|
|
125
102
|
if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
|
|
126
103
|
models.each do |name|
|
|
127
104
|
model = SuperAuth.const_get(name)
|
|
128
|
-
model.
|
|
105
|
+
if @db.nil? || model.db.equal?(@db)
|
|
106
|
+
model.set_dataset(model.dataset)
|
|
107
|
+
else
|
|
108
|
+
# A dataset on the target database is the one way to move a model
|
|
109
|
+
# that already has one: Sequel refuses Model.db= after that point.
|
|
110
|
+
# A host may set SuperAuth.db before its migrations have run, so a
|
|
111
|
+
# missing table moves the binding and leaves the columns to the
|
|
112
|
+
# next refresh, which install_migrations makes.
|
|
113
|
+
begin
|
|
114
|
+
model.set_dataset(@db[model.table_name])
|
|
115
|
+
rescue Sequel::DatabaseError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
129
119
|
end
|
|
130
120
|
end
|
|
131
121
|
end
|
|
@@ -154,6 +144,20 @@ module SuperAuth
|
|
|
154
144
|
# the connection that holds the transaction.
|
|
155
145
|
def self.as(user, db: SuperAuth.db, **options)
|
|
156
146
|
previous = current_user
|
|
147
|
+
unless rls?(db)
|
|
148
|
+
# No policy on this database reads the identity, so there is nothing to
|
|
149
|
+
# assert and no reason to open a transaction for it — the ORM scope
|
|
150
|
+
# reads current_user and nothing else. This is the shape of a host that
|
|
151
|
+
# has not turned RLS on, and of every host on SQLite or MySQL, where
|
|
152
|
+
# RLS.as raises. Turning RLS on later needs no application change: the
|
|
153
|
+
# same call starts asserting both layers. `options` is dropped rather
|
|
154
|
+
# than passed on: every one of them is a Sequel transaction option
|
|
155
|
+
# (auto_savepoint:, isolation:, ...) and there is no transaction here to
|
|
156
|
+
# give them to. Anything that ever means something outside one has to be
|
|
157
|
+
# handled before this return, not added to the splat.
|
|
158
|
+
self.current_user = user
|
|
159
|
+
return yield
|
|
160
|
+
end
|
|
157
161
|
SuperAuth::RLS.as(user, db: db, **options) do
|
|
158
162
|
self.current_user = user
|
|
159
163
|
yield
|
|
@@ -162,6 +166,21 @@ module SuperAuth
|
|
|
162
166
|
self.current_user = previous
|
|
163
167
|
end
|
|
164
168
|
|
|
169
|
+
# Whether `db` carries the RLS functions — the question `as` asks on every
|
|
170
|
+
# block, so the answer is memoised for SuperAuth.db: it is a catalogue
|
|
171
|
+
# round trip, and only a migration changes it. RLS.enable, which is what
|
|
172
|
+
# creates them, clears it; so does SuperAuth.db=. Call `rls!` after
|
|
173
|
+
# installing them some other way in a live process.
|
|
174
|
+
def self.rls?(db = SuperAuth.db)
|
|
175
|
+
return SuperAuth::RLS.installed?(db: db) unless db.equal?(@db)
|
|
176
|
+
@rls = SuperAuth::RLS.installed?(db: db) if @rls.nil?
|
|
177
|
+
@rls
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def self.rls!
|
|
181
|
+
@rls = nil
|
|
182
|
+
end
|
|
183
|
+
|
|
165
184
|
# Both user models are internal: their id is the user_id that the policies
|
|
166
185
|
# and ByCurrentUser match on. Anything else is an application object,
|
|
167
186
|
# matched by id and class name.
|
|
@@ -238,8 +257,11 @@ module SuperAuth
|
|
|
238
257
|
end
|
|
239
258
|
end
|
|
240
259
|
|
|
260
|
+
# Models already loaded follow the new database; see refresh_model_schemas.
|
|
241
261
|
def self.db=(db)
|
|
242
262
|
@db = db
|
|
263
|
+
@rls = nil
|
|
264
|
+
refresh_model_schemas if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
|
|
243
265
|
end
|
|
244
266
|
end
|
|
245
267
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: super_auth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonathan Frias
|
|
@@ -57,6 +57,8 @@ files:
|
|
|
57
57
|
- config/routes.rb
|
|
58
58
|
- db/migrate/10_add_super_auth_label_to_resources.rb
|
|
59
59
|
- db/migrate/11_add_parent_id_to_resources.rb
|
|
60
|
+
- db/migrate/12_add_resource_indexes.rb
|
|
61
|
+
- db/migrate/13_add_resource_tree_guard.rb
|
|
60
62
|
- db/migrate/1_users.rb
|
|
61
63
|
- db/migrate/2_groups.rb
|
|
62
64
|
- db/migrate/3_permissions.rb
|
|
@@ -76,6 +78,8 @@ files:
|
|
|
76
78
|
- db/migrate_activerecord/20250101000009_add_by_current_user_index_to_super_auth_authorizations.rb
|
|
77
79
|
- db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb
|
|
78
80
|
- db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb
|
|
81
|
+
- db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb
|
|
82
|
+
- db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb
|
|
79
83
|
- db/seeds/sample_data.rb
|
|
80
84
|
- exe/super_auth-editor
|
|
81
85
|
- lib/basic_loader.rb
|
|
@@ -90,6 +94,7 @@ files:
|
|
|
90
94
|
- lib/super_auth/active_record/by_current_user.rb
|
|
91
95
|
- lib/super_auth/active_record/edge.rb
|
|
92
96
|
- lib/super_auth/active_record/group.rb
|
|
97
|
+
- lib/super_auth/active_record/nested.rb
|
|
93
98
|
- lib/super_auth/active_record/permission.rb
|
|
94
99
|
- lib/super_auth/active_record/resource.rb
|
|
95
100
|
- lib/super_auth/active_record/role.rb
|
|
@@ -104,9 +109,11 @@ files:
|
|
|
104
109
|
- lib/super_auth/nestable.rb
|
|
105
110
|
- lib/super_auth/permission.rb
|
|
106
111
|
- lib/super_auth/railtie.rb
|
|
112
|
+
- lib/super_auth/reach.rb
|
|
107
113
|
- lib/super_auth/resource.rb
|
|
108
114
|
- lib/super_auth/rls.rb
|
|
109
115
|
- lib/super_auth/role.rb
|
|
116
|
+
- lib/super_auth/tree_guard.rb
|
|
110
117
|
- lib/super_auth/user.rb
|
|
111
118
|
- lib/super_auth/version.rb
|
|
112
119
|
- lib/tasks/super_auth_tasks.rake
|