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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +440 -42
  5. data/USAGE.md +127 -21
  6. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  7. data/db/migrate/12_add_resource_indexes.rb +116 -0
  8. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  9. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  10. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  11. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  12. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  13. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  14. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  16. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  17. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  18. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  19. data/db/seeds/sample_data.rb +1 -0
  20. data/lib/generators/super_auth/install/templates/README +6 -2
  21. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  22. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  23. data/lib/super_auth/active_record/authorization.rb +14 -3
  24. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  25. data/lib/super_auth/active_record/group.rb +3 -0
  26. data/lib/super_auth/active_record/nested.rb +43 -0
  27. data/lib/super_auth/active_record/resource.rb +28 -4
  28. data/lib/super_auth/active_record/role.rb +3 -0
  29. data/lib/super_auth/active_record.rb +30 -2
  30. data/lib/super_auth/authorization.rb +63 -10
  31. data/lib/super_auth/edge.rb +73 -18
  32. data/lib/super_auth/editor/index.html +16 -10
  33. data/lib/super_auth/editor/seed.rb +11 -5
  34. data/lib/super_auth/editor.rb +35 -11
  35. data/lib/super_auth/nestable.rb +105 -4
  36. data/lib/super_auth/reach.rb +88 -0
  37. data/lib/super_auth/resource.rb +71 -0
  38. data/lib/super_auth/rls.rb +576 -44
  39. data/lib/super_auth/tree_guard.rb +115 -0
  40. data/lib/super_auth/version.rb +1 -1
  41. data/lib/super_auth.rb +55 -2
  42. 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
@@ -1,3 +1,3 @@
1
1
  module SuperAuth
2
- VERSION = "0.7.0"
2
+ VERSION = "0.9.0"
3
3
  end
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.set_dataset(model.dataset)
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.7.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