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
|
@@ -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,4 +1,6 @@
|
|
|
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
|
|
@@ -83,7 +85,13 @@ module SuperAuth
|
|
|
83
85
|
|
|
84
86
|
# Both ORMs cache column types per model class; after (re)installing the
|
|
85
87
|
# migrations those caches can describe a previous schema (e.g. a different
|
|
86
|
-
# 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.
|
|
87
95
|
def self.refresh_model_schemas
|
|
88
96
|
models = %w[User Group Permission Role Resource Edge Authorization]
|
|
89
97
|
if defined?(SuperAuth::ActiveRecord::User)
|
|
@@ -94,7 +102,20 @@ module SuperAuth
|
|
|
94
102
|
if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
|
|
95
103
|
models.each do |name|
|
|
96
104
|
model = SuperAuth.const_get(name)
|
|
97
|
-
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
|
|
98
119
|
end
|
|
99
120
|
end
|
|
100
121
|
end
|
|
@@ -123,6 +144,20 @@ module SuperAuth
|
|
|
123
144
|
# the connection that holds the transaction.
|
|
124
145
|
def self.as(user, db: SuperAuth.db, **options)
|
|
125
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
|
|
126
161
|
SuperAuth::RLS.as(user, db: db, **options) do
|
|
127
162
|
self.current_user = user
|
|
128
163
|
yield
|
|
@@ -131,6 +166,21 @@ module SuperAuth
|
|
|
131
166
|
self.current_user = previous
|
|
132
167
|
end
|
|
133
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
|
+
|
|
134
184
|
# Both user models are internal: their id is the user_id that the policies
|
|
135
185
|
# and ByCurrentUser match on. Anything else is an application object,
|
|
136
186
|
# matched by id and class name.
|
|
@@ -207,8 +257,11 @@ module SuperAuth
|
|
|
207
257
|
end
|
|
208
258
|
end
|
|
209
259
|
|
|
260
|
+
# Models already loaded follow the new database; see refresh_model_schemas.
|
|
210
261
|
def self.db=(db)
|
|
211
262
|
@db = db
|
|
263
|
+
@rls = nil
|
|
264
|
+
refresh_model_schemas if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
|
|
212
265
|
end
|
|
213
266
|
end
|
|
214
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
|
|
@@ -56,6 +56,9 @@ files:
|
|
|
56
56
|
- USAGE.md
|
|
57
57
|
- config/routes.rb
|
|
58
58
|
- db/migrate/10_add_super_auth_label_to_resources.rb
|
|
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
|
|
59
62
|
- db/migrate/1_users.rb
|
|
60
63
|
- db/migrate/2_groups.rb
|
|
61
64
|
- db/migrate/3_permissions.rb
|
|
@@ -74,6 +77,9 @@ files:
|
|
|
74
77
|
- db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb
|
|
75
78
|
- db/migrate_activerecord/20250101000009_add_by_current_user_index_to_super_auth_authorizations.rb
|
|
76
79
|
- db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb
|
|
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
|
|
77
83
|
- db/seeds/sample_data.rb
|
|
78
84
|
- exe/super_auth-editor
|
|
79
85
|
- lib/basic_loader.rb
|
|
@@ -88,6 +94,7 @@ files:
|
|
|
88
94
|
- lib/super_auth/active_record/by_current_user.rb
|
|
89
95
|
- lib/super_auth/active_record/edge.rb
|
|
90
96
|
- lib/super_auth/active_record/group.rb
|
|
97
|
+
- lib/super_auth/active_record/nested.rb
|
|
91
98
|
- lib/super_auth/active_record/permission.rb
|
|
92
99
|
- lib/super_auth/active_record/resource.rb
|
|
93
100
|
- lib/super_auth/active_record/role.rb
|
|
@@ -102,9 +109,11 @@ files:
|
|
|
102
109
|
- lib/super_auth/nestable.rb
|
|
103
110
|
- lib/super_auth/permission.rb
|
|
104
111
|
- lib/super_auth/railtie.rb
|
|
112
|
+
- lib/super_auth/reach.rb
|
|
105
113
|
- lib/super_auth/resource.rb
|
|
106
114
|
- lib/super_auth/rls.rb
|
|
107
115
|
- lib/super_auth/role.rb
|
|
116
|
+
- lib/super_auth/tree_guard.rb
|
|
108
117
|
- lib/super_auth/user.rb
|
|
109
118
|
- lib/super_auth/version.rb
|
|
110
119
|
- lib/tasks/super_auth_tasks.rake
|