super_auth 0.3.3 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a2db33b2e48f7e542d54f3b5cef127f51c09fe19896b4ec6644a65ffa424c91
4
- data.tar.gz: 87a7b993edea7bab1ca6c3298805f17e62ce0cd8b67de9114167cdb868316d7d
3
+ metadata.gz: 8d15415f81b29b08a690f0223fe01fd888177085f479adb902ab5103002e7292
4
+ data.tar.gz: 6bc43afccfc699cbf2f8d67c6af32226cfa8989f2e188bbe919835529b488a43
5
5
  SHA512:
6
- metadata.gz: 62f14629c43e5dae082d3884803dae6559a91c58118479fc1fc6e4496dec27036c35cf8011e859b16f0e5a30d9c977244d2c39d005936a015796ddd1845b87ee
7
- data.tar.gz: ee2cfaa0d1d3d4b8e3ba56130bc47cdbcb491982fac339bcde956a555b267dc8b39c82da284427f4b7e0baeb6382a5532766135d96922ecd1170079a0333c2f5
6
+ metadata.gz: 8fc62a59a8291668b01d3a4a012422f764302534f5acc7be6d5954e2dfa4427cd69d7b5f81d65fb96a9be77f1c3c328ce4785071ba2310e7c2cbecf1c1174f9f
7
+ data.tar.gz: c5012e5e5fdebb90c46326ed8438e939b4c37562730aa33078eecc9dd74debb07c0154509c90549c4dbc783aa4f14e7d2353e63d814571e22e018f45439d3991
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-09-02
4
+
5
+ ### Security
6
+
7
+ - Fix: path strategy 1 (users <-> groups <-> roles <-> permissions <-> resources) granted every role held by any group to the members of every group that held a role. `SuperAuth::Edge.users_groups_roles_permissions_resources` built one set of all role-holding groups and one set of all group-held roles and cross-joined them with nothing correlating a group to its own role. The role lookup is now joined through the member's own group ancestry, so a role attached to one group never reaches members of an unrelated group. Affects `SuperAuth::Edge.authorizations` and anything compiled from it (`SuperAuth::ActiveRecord::Authorization.compile!`); recompile authorizations after upgrading.
8
+
9
+ ### Added
10
+
11
+ - Postgres row-level security enforcement (`SuperAuth::RLS`, `rails g super_auth:rls Model ...`). Identity is anchored to the transaction by the `super_auth_become()` SQL function, exposed in Ruby as `SuperAuth.as(user) { ... }`, so non-Ruby clients get the same enforcement.
12
+ - Permission-gated subclass loading: a `ByCurrentUser` subclass is its own resource type, so privileged methods can live on a subclass whose access must be granted explicitly. A grant on the base class does not flow down.
13
+ - `SuperAuth.external_id_type` types the external id columns at install time instead of casting at query time.
14
+
15
+ ### Changed
16
+
17
+ - Relicensed from MIT to GPL-2.0.
18
+ - Path strategies 1, 2 and 3 join group ancestry and role subtrees on integer pairs from two new recursive CTEs (`Group.ancestor_pairs`, `Role.descendant_pairs`) instead of LIKE-matching ids inside the comma-separated path strings, which no planner can index. Output is unchanged. On a 10,000-user graph the full `authorizations` union went from 10.4 s to 2.5 s on Postgres 16; on MySQL 8 a 500-user graph went from 9.9 s to 0.08 s, and on SQLite strategy 1 alone went from over 400 s to 0.01 s.
19
+
20
+ ### Fixed
21
+
22
+ - MySQL 8 support. `SuperAuth::Edge.authorizations` raised "Illegal mix of collations for operation 'UNION'" whenever the connection collation differed from the table collation, which it does under ActiveRecord's defaults, so `compile!` could never run on MySQL. The recursive tree CTEs typed their path columns from the anchor row, so any `group_path` or `role_path` over 11 characters, or name path over 255, failed with "Data too long". Migration 8 no longer adds edge indexes on MySQL, where InnoDB already indexes foreign keys and refuses to drop them, which had broken `uninstall_migrations`. CI now runs the suite against real MySQL instead of silently falling back to SQLite.
23
+
3
24
  ## [0.3.3] - 2026-04-29
4
25
 
5
26
  - Fix: detect PostgreSQL/SQLite/Mysql2 adapter subclasses (e.g. PostGIS, Makara) when bootstrapping the Sequel connection from ActiveRecord. Previously only the exact stock adapter classes were recognized, leaving `SuperAuth.db` unset for apps using a subclassed adapter.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- super_auth (0.3.3)
4
+ super_auth (0.4.0)
5
5
  sequel
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -2,16 +2,23 @@
2
2
 
3
3
  [![Build Status](https://github.com/JonathanFrias/super_auth/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/JonathanFrias/super_auth/actions)
4
4
 
5
- Super auth is turn-key authorization gem that makes unauthorized access unrepresentable. **Stop writing tests for authorization with confidence**
5
+ Super auth is a turn-key authorization engine that makes unauthorized access unrepresentable — enforced in your database, so the same rules protect every client, in any language, that touches your data. **Stop writing authorization tests; enforce access with confidence.**
6
6
 
7
- The intent is to use with ruby applications, as well as centralize authorization for multiple applications. If you look at the [OWASP top vulnerabilty](https://owasp.org/Top10/A01_2021-Broken_Access_Control/), broken
8
- access control is the NUMBER 1 most common security risk in modern applications today. super_auth provides a authentication strategy that allows you to completely de-risk your application, solving this issue once confidently.
7
+ The intent is to centralize authorization for one application or many, in any language. If you look at the [OWASP top vulnerability](https://owasp.org/Top10/A01_2021-Broken_Access_Control/), broken
8
+ access control is the NUMBER 1 most common security risk in modern applications today. super_auth provides an authorization model that lets you de-risk your application, solving this issue once, confidently.
9
9
 
10
10
 
11
11
  ## Installation
12
12
 
13
+ SuperAuth enforces authorization in the database, so any language can participate. The reference client is the Ruby gem:
14
+
13
15
  gem "super_auth"
14
16
 
17
+ ## Supported databases
18
+
19
+ PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group and role trees are recursive
20
+ CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
21
+ suite against each of the three. Row-level security is Postgres only.
15
22
 
16
23
  ## Docs
17
24
 
@@ -47,6 +54,124 @@ Then visit: `http://localhost:3000/super_auth/visualization`
47
54
 
48
55
  See [VISUALIZATION.md](VISUALIZATION.md) for complete documentation.
49
56
 
57
+ ## Postgres Row-Level Security (optional)
58
+
59
+ The `ByCurrentUser` scope enforces authorization at the ORM layer. On Postgres you can
60
+ additionally enforce the same rules inside the database itself, so raw SQL, `unscoped`,
61
+ background jobs, and any other client on the same database are subject to them too —
62
+ unauthorized rows become invisible at the connection level. Enforcement is pure SQL:
63
+ participating apps don't load this gem, or Ruby, at all. The gem's role is
64
+ administrative — define the graph, compile authorizations, enable the policies — which
65
+ is what makes super_auth usable as a central authorization service for apps in any
66
+ language.
67
+
68
+ ### The contract (any language)
69
+
70
+ Identity is asserted per transaction by calling the `super_auth_become` function that
71
+ `SuperAuth::RLS.enable` installs:
72
+
73
+ ```sql
74
+ BEGIN;
75
+ SELECT super_auth_become(user_external_id => '42', user_external_type => 'AppUser');
76
+ -- run normal queries; rows the user isn't authorized for don't exist --
77
+ COMMIT; -- identity dies with the transaction; there is nothing to clear
78
+ ```
79
+
80
+ For a user managed inside super_auth, pass `user_id => '7'` instead; `system => true`
81
+ bypasses the policies (migrations, seeds, admin jobs).
82
+
83
+ The assertion is anchored to the calling transaction: `super_auth_become` sets
84
+ transaction-local identity settings plus a stamp of the current transaction id, and
85
+ every policy requires a stamp from the current transaction. Outside a transaction the
86
+ settings have already reverted, and identity smuggled in as session settings carries a
87
+ dead transaction's stamp — either way queries return no rows and writes are rejected.
88
+ Misuse fails closed, and the scheme works unchanged behind transaction-pooling proxies
89
+ like pgbouncer, because a transaction is exactly what they keep on one server
90
+ connection.
91
+
92
+ ### Setup (Rails)
93
+
94
+ **1. Match column types to your primary keys — before your first migration.**
95
+ The policies compare `super_auth_authorizations.resource_external_id` directly
96
+ against your tables' pks with no casting, so the columns must share a type:
97
+
98
+ ```ruby
99
+ # config/initializers/super_auth.rb
100
+ SuperAuth.setup do |config|
101
+ config.external_id_type = :bigint # Rails' default pk type; use :uuid, :string, ... to match yours
102
+ end
103
+ ```
104
+
105
+ If super_auth is already migrated with the wrong type, alter the four external id
106
+ columns (`super_auth_users.external_id`, `super_auth_resources.external_id`,
107
+ `super_auth_authorizations.user_external_id`, `super_auth_authorizations.resource_external_id`)
108
+ in a migration of your own.
109
+
110
+ **2. Enable RLS on the tables you want protected:**
111
+
112
+ ```bash
113
+ rails generate super_auth:rls Document Invoice
114
+ rails db:migrate
115
+ ```
116
+
117
+ This creates one migration calling `SuperAuth::RLS.enable(:documents, resource_type: "Document")`
118
+ per model — you can also call that directly for tables outside Rails. `resource_type`
119
+ must match the `resource_external_type` used in your authorization rows (the model's
120
+ class name when you use the AR integration).
121
+
122
+ **3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
123
+ policies entirely, so the app must not connect as one (owning the tables is fine —
124
+ the policies use `FORCE ROW LEVEL SECURITY`). The role needs `SELECT` on
125
+ `super_auth_authorizations`, which the policies read; `EXECUTE` on
126
+ `super_auth_become` is granted to `PUBLIC` by default, so no extra grant is needed:
127
+
128
+ ```sql
129
+ CREATE ROLE app_runtime LOGIN PASSWORD '...';
130
+ GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
131
+ GRANT SELECT ON super_auth_authorizations TO app_runtime;
132
+ ```
133
+
134
+ > ⚠️ **This is the one step that, if skipped, silently disables all protection.**
135
+ > PostgreSQL *always* lets **superusers** and roles with the **`BYPASSRLS`** attribute
136
+ > bypass row-level security. `FORCE ROW LEVEL SECURITY` only subjects the table *owner*
137
+ > to the policies — it does **not** constrain a superuser. So if your app connects to
138
+ > Postgres as a superuser (the default in many local setups and some managed hosts), the
139
+ > policies apply to nobody and every row stays visible, while everything *looks* like it
140
+ > is working. Always connect as a dedicated non-superuser, non-`BYPASSRLS` role such as
141
+ > `app_runtime` above. SuperAuth's language-specific clients check this on startup and
142
+ > warn you when the connection is able to bypass RLS.
143
+
144
+ **4. Wrap work in an identity assertion.** In Ruby:
145
+
146
+ ```ruby
147
+ SuperAuth.as(current_user) do
148
+ # every query in here is enforced by the database
149
+ end
150
+ ```
151
+
152
+ `SuperAuth.as` opens a transaction and calls `super_auth_become` for you — use it in
153
+ an `around_action` (or around a job) to cover a whole request. Non-Ruby apps use the
154
+ SQL contract directly. Each policy checks `super_auth_authorizations` with the same
155
+ semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
156
+ act as a wildcard, per-record authorizations match on id, `system?` users bypass.
157
+
158
+ ### Notes
159
+
160
+ - Queries with no identity asserted see nothing, and writes are rejected — fail
161
+ closed, by design. A client that has never heard of super_auth cannot accidentally
162
+ reach protected rows.
163
+ - Creating rows requires a type-level authorization for that resource type (or system
164
+ context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
165
+ `USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs.
166
+ - The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
167
+ id even to read-only transactions — one extra xid per protected transaction.
168
+ Negligible for almost everyone; revisit with a virtual-xid variant only if
169
+ transaction id churn ever matters at extreme read volume.
170
+ - One `external_id_type` covers the whole install, so every protected table across
171
+ every participating app needs the same pk type.
172
+ - Postgres 13+ only (`pg_current_xact_id`). On other databases `SuperAuth::RLS`
173
+ raises, and the ORM scope remains the enforcement layer.
174
+
50
175
  ## Configuration
51
176
 
52
177
  ```ruby
@@ -55,12 +180,18 @@ SuperAuth.setup do |config|
55
180
  # Raise an error when a query runs without a current user set.
56
181
  # Default is :none (returns empty results silently).
57
182
  config.missing_user_behavior = :raise
183
+
184
+ # Column type for external id columns, applied when the migrations run. Set
185
+ # it to your application's primary key type (:bigint, :uuid, :string, ...) so
186
+ # authorization comparisons are natively typed. Default is :string.
187
+ config.external_id_type = :bigint
58
188
  end
59
189
  ```
60
190
 
61
191
  | Option | Values | Default | Description |
62
192
  |--------|--------|---------|-------------|
63
193
  | `missing_user_behavior` | `:none`, `:raise` | `:none` | Controls what happens when `SuperAuth.current_user` is blank. `:none` returns an empty result set. `:raise` raises `SuperAuth::Error`. |
194
+ | `external_id_type` | `:string`, `:bigint`, `:uuid`, ... | `:string` | Column type for the external id columns, applied when the migrations run. Set it to your application's primary key type so every comparison against your tables' pks is natively typed — no casting anywhere. |
64
195
 
65
196
  ## Usage
66
197
 
@@ -75,6 +206,9 @@ SuperAuth is a rules engine engine that works on 5 different authorization conce
75
206
  The basis for how this works is that the rules engine is trying to match a user with a resource to determine access.
76
207
  The engine determines if it can find an authorization route betewen a user and a resource. It does so by looking at users, groups, roles, permissions.
77
208
 
209
+ +---+ +---+
210
+ | | | | (Group nests within Group,
211
+ | v | v Role nests within Role)
78
212
  +-------+ +------+
79
213
  | Group |<----->| Role |
80
214
  +-------+\ / +------+
@@ -92,7 +226,9 @@ The engine determines if it can find an authorization route betewen a user and a
92
226
 
93
227
 
94
228
  The lines between the boxes are called [edges](https://en.wikipedia.org/wiki/Glossary_of_graph_theory#edge).
95
- Note that `Group` and `Role` trees.
229
+ The self-loops on `Group` and `Role` mean each nests within itself: a `Group` can contain
230
+ child `Group`s and a `Role` can contain child `Role`s, recursively. Grants on a parent
231
+ flow to every descendant — which is why `Group` and `Role` are described as *trees*.
96
232
 
97
233
  In general the super_auth has 5 different pathing strategies to search for access.
98
234
 
@@ -210,6 +346,68 @@ Since the path is stored with the record, it trivial to audit access permissions
210
346
 
211
347
  TODO: Write usage instructions here
212
348
 
349
+ ## Permission-Gated Models
350
+
351
+ Every class is authorized by its own name — nothing is derived, and a grant on one class never flows to another. That makes a subclass the natural home for privileged methods: it shares the base class's table and rows, but loading it requires its own, explicitly approved grant. If you can't load the object, you can't call its methods.
352
+
353
+ ```ruby
354
+ class Resource < ApplicationRecord
355
+ super_auth
356
+ # Loadable by users granted the "Resource" resource type.
357
+
358
+ class ResourceRestartPermission < Resource
359
+ # Loadable ONLY by users granted "Resource::ResourceRestartPermission".
360
+ def restart!
361
+ # dangerous restart operation
362
+ end
363
+ end
364
+ end
365
+ ```
366
+
367
+ Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it:
368
+
369
+ ```ruby
370
+ restartable = SuperAuth::Resource.create(
371
+ name: "restartable servers",
372
+ external_type: "Resource::ResourceRestartPermission"
373
+ )
374
+ restart = SuperAuth::Permission.create(name: "restart")
375
+ SuperAuth::Edge.create(user: sa_user, permission: restart)
376
+ SuperAuth::Edge.create(permission: restart, resource: restartable)
377
+ SuperAuth::ActiveRecord::Authorization.compile!
378
+
379
+ Resource.find(id) # needs a "Resource" grant
380
+ Resource::ResourceRestartPermission.find(id) # needs its own explicit approval
381
+ ```
382
+
383
+ Grants are per class in both directions: a `"Resource"` grant does not unlock the subclass, and a `"Resource::ResourceRestartPermission"` grant does not unlock the base class.
384
+
385
+ ## Row-Level Security for permission-gated models
386
+
387
+ For defense in depth on Postgres (13+), enable a policy on the table. It is keyed by a single resource type — the base class's name — and enforces *row visibility* using the same [contract described above](#postgres-row-level-security-optional):
388
+
389
+ ```ruby
390
+ SuperAuth::RLS.enable(:resources, resource_type: "Resource")
391
+ ```
392
+
393
+ `enable` turns on `ROW LEVEL SECURITY` (with `FORCE`, so the table owner is covered too) and installs a policy that derives visibility from `super_auth_authorizations`. Identity is asserted **per transaction, not per connection**: wrap the work in `SuperAuth.as`, which opens a transaction and calls `super_auth_become` for you (see the contract above). Every query inside is filtered, and the identity dies with the transaction:
394
+
395
+ ```ruby
396
+ SuperAuth.as(current_user) do
397
+ SuperAuth.db[:resources].all # only rows current_user holds a grant on
398
+ end
399
+ # outside the block there is no asserted identity, so the policy matches nothing
400
+ ```
401
+
402
+ Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level wildcard grants (`resource_external_id IS NULL`) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
403
+
404
+ Because a policy sees only the table, not which Ruby class issued the query, row-level security enforces access to the **base** resource type: a `"Resource"` grant makes the row visible in the database, but the policy cannot distinguish the `"Resource::ResourceRestartPermission"` subclass. Per-class (capability) enforcement therefore stays with the ORM scope — the database is the row-visibility backstop, the client gates the capability.
405
+
406
+ Notes:
407
+
408
+ - With no `SuperAuth.as` assertion in effect, the policy matches nothing (deny by default) and writes are rejected — fail closed.
409
+ - Postgres superusers and `BYPASSRLS` roles bypass row-level security entirely — run your application as a regular role (see the setup guide above).
410
+
213
411
  ## Development
214
412
 
215
413
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -222,4 +420,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/Jonath
222
420
 
223
421
  ## License
224
422
 
225
- The gem is available as open source under the terms of the [GPL](https://www.gnu.org/licenses/quick-guide-gplv3.html).
423
+ The gem is available as open source under the terms of the [GPL v2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html).
data/USAGE.md CHANGED
@@ -436,6 +436,29 @@ SuperAuth.current_user = SuperAuth::User.system
436
436
  Post.all # Returns all posts
437
437
  ```
438
438
 
439
+ ### Permission-gated subclasses
440
+
441
+ Every class is authorized by its own name, so privileged methods belong on a subclass: it shares the base class's table and rows, but loading it requires its own explicitly approved grant — access never flows between base and subclass in either direction:
442
+
443
+ ```ruby
444
+ class Post < ApplicationRecord
445
+ super_auth
446
+
447
+ class PostPublishPermission < Post
448
+ def publish!
449
+ update!(published: true)
450
+ end
451
+ end
452
+ end
453
+
454
+ Post.find(id) # needs a "Post" grant
455
+ Post::PostPublishPermission.find(id) # needs a "Post::PostPublishPermission" grant
456
+ ```
457
+
458
+ Approve the subclass like any other resource — register a `SuperAuth::Resource` with `external_type: "Post::PostPublishPermission"` and draw edges to it, then recompile with `SuperAuth::ActiveRecord::Authorization.compile!`.
459
+
460
+ For database-side enforcement of the same rules see "Postgres Row-Level Security" in the README (`SuperAuth::RLS.enable` — visibility is derived automatically from `SuperAuth.current_user`).
461
+
439
462
  ### Linking to your app's models
440
463
 
441
464
  Connect SuperAuth entities to your ActiveRecord models via `external_id` and `external_type`:
@@ -616,4 +639,4 @@ auths = SuperAuth::Edge.authorizations.all
616
639
 
617
640
  ## License
618
641
 
619
- SuperAuth is available as open source under the [GPL License](https://www.gnu.org/licenses/quick-guide-gplv3.html).
642
+ SuperAuth is available as open source under the [GPL v2 License](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html).
@@ -2,7 +2,7 @@ Sequel.migration do
2
2
  up do
3
3
  create_table(:super_auth_users) do
4
4
  primary_key :id
5
- String :external_id # , null: false
5
+ column :external_id, SuperAuth.sequel_external_id_type # , null: false
6
6
  String :external_type # , null: false
7
7
  String :name
8
8
  DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
@@ -3,7 +3,7 @@ Sequel.migration do
3
3
  create_table(:super_auth_resources) do
4
4
  primary_key :id
5
5
  String :name
6
- String :external_id # , null: false
6
+ column :external_id, SuperAuth.sequel_external_id_type # , null: false
7
7
  String :external_type # , null: false
8
8
  DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
9
9
  DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
@@ -3,7 +3,7 @@ Sequel.migration do
3
3
  create_table(:super_auth_authorizations) do
4
4
  Integer :user_id, null: true
5
5
  String :user_name, null: true
6
- String :user_external_id, null: true
6
+ column :user_external_id, SuperAuth.sequel_external_id_type, null: true
7
7
  String :user_external_type, null: true
8
8
  DateTime :user_created_at, null: true
9
9
  DateTime :user_updated_at, null: true
@@ -28,7 +28,7 @@ Sequel.migration do
28
28
  DateTime :permission_updated_at, null: true
29
29
  Integer :resource_id, null: true
30
30
  String :resource_name, null: true
31
- String :resource_external_id, null: true
31
+ column :resource_external_id, SuperAuth.sequel_external_id_type, null: true
32
32
  String :resource_external_type, null: true
33
33
  DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
34
34
  DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
@@ -1,5 +1,10 @@
1
1
  Sequel.migration do
2
+ # InnoDB already creates an index for every foreign key column, and will not
3
+ # drop one a constraint depends on, so on MySQL these indexes are redundant
4
+ # and their removal would fail. Skip them there.
2
5
  up do
6
+ next if [:mysql, :mysql2].include?(database_type)
7
+
3
8
  add_index :super_auth_edges, :user_id
4
9
  add_index :super_auth_edges, :group_id
5
10
  add_index :super_auth_edges, :role_id
@@ -8,6 +13,8 @@ Sequel.migration do
8
13
  end
9
14
 
10
15
  down do
16
+ next if [:mysql, :mysql2].include?(database_type)
17
+
11
18
  drop_index :super_auth_edges, :user_id
12
19
  drop_index :super_auth_edges, :group_id
13
20
  drop_index :super_auth_edges, :role_id
@@ -1,7 +1,7 @@
1
1
  class CreateSuperAuthUsers < ActiveRecord::Migration[7.0]
2
2
  def change
3
3
  create_table :super_auth_users do |t|
4
- t.string :external_id
4
+ t.column :external_id, SuperAuth.external_id_type
5
5
  t.string :external_type
6
6
  t.string :name
7
7
  t.timestamps default: -> { "CURRENT_TIMESTAMP" }
@@ -2,7 +2,7 @@ class CreateSuperAuthResources < ActiveRecord::Migration[7.0]
2
2
  def change
3
3
  create_table :super_auth_resources do |t|
4
4
  t.string :name
5
- t.string :external_id
5
+ t.column :external_id, SuperAuth.external_id_type
6
6
  t.string :external_type
7
7
  t.timestamps default: -> { "CURRENT_TIMESTAMP" }
8
8
  end
@@ -3,7 +3,7 @@ class CreateSuperAuthAuthorizations < ActiveRecord::Migration[7.0]
3
3
  create_table :super_auth_authorizations do |t|
4
4
  t.integer :user_id
5
5
  t.string :user_name
6
- t.string :user_external_id
6
+ t.column :user_external_id, SuperAuth.external_id_type
7
7
  t.string :user_external_type
8
8
  t.datetime :user_created_at
9
9
  t.datetime :user_updated_at
@@ -32,7 +32,7 @@ class CreateSuperAuthAuthorizations < ActiveRecord::Migration[7.0]
32
32
 
33
33
  t.integer :resource_id
34
34
  t.string :resource_name
35
- t.string :resource_external_id
35
+ t.column :resource_external_id, SuperAuth.external_id_type
36
36
  t.string :resource_external_type
37
37
 
38
38
  t.timestamps default: -> { "CURRENT_TIMESTAMP" }
@@ -2,8 +2,19 @@
2
2
  # The SuperAuth Railtie automatically connects to your database and loads all
3
3
  # models on boot. Use this file for any additional configuration.
4
4
  #
5
- # SuperAuth.setup do |config|
6
- # # Raise an error when a query runs without a current user set.
7
- # # Default is :none (returns empty results silently).
8
- # # config.missing_user_behavior = :raise
9
- # end
5
+ SuperAuth.setup do |config|
6
+ # Column type for external id columns, created when the migrations run.
7
+ # Match your application's primary key type (:bigint, :uuid, :string, ...)
8
+ # so comparisons against your tables' pks are natively typed. Rails
9
+ # defaults to bigint pks. Must be set before running super_auth migrations.
10
+ config.external_id_type = :bigint
11
+
12
+ # Raise an error when a query runs without a current user set.
13
+ # Default is :none (returns empty results silently).
14
+ # config.missing_user_behavior = :raise
15
+
16
+ # Postgres row-level security: enable it per table with
17
+ # rails generate super_auth:rls Model ...
18
+ # then wrap request work in SuperAuth.as(current_user) { ... } wherever
19
+ # database-level enforcement should apply.
20
+ end
@@ -0,0 +1,22 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module SuperAuth
5
+ module Generators
6
+ class RlsGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ # The model class names typed on the command line, verbatim:
12
+ # `rails g super_auth:rls Document Invoice` => ["Document", "Invoice"]
13
+ argument :model_names, type: :array, banner: "Model Model ..."
14
+
15
+ desc "Creates a migration enabling Postgres row-level security for the given models"
16
+
17
+ def create_migration_file
18
+ migration_template 'migration.rb.erb', 'db/migrate/enable_super_auth_rls.rb'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,13 @@
1
+ class EnableSuperAuthRls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def up
3
+ <% model_names.each do |model| -%>
4
+ SuperAuth::RLS.enable(:<%= model.tableize.tr('/', '_') %>, resource_type: "<%= model.camelize %>")
5
+ <% end -%>
6
+ end
7
+
8
+ def down
9
+ <% model_names.each do |model| -%>
10
+ SuperAuth::RLS.disable(:<%= model.tableize.tr('/', '_') %>)
11
+ <% end -%>
12
+ end
13
+ end
@@ -1,4 +1,24 @@
1
1
  module SuperAuth::ActiveRecord::ByCurrentUser
2
+ # Records are filtered to those the current user holds an authorization for,
3
+ # keyed by the querying class's name. Because a subclass is its own resource
4
+ # type, privileged methods can be placed on a subclass whose access must be
5
+ # approved explicitly — a grant on the base class does not flow down:
6
+ #
7
+ # class Resource < ApplicationRecord
8
+ # super_auth
9
+ #
10
+ # class ResourceRestartPermission < Resource
11
+ # def restart!
12
+ # # dangerous restart operation
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # Resource::ResourceRestartPermission shares the base class's table and rows,
18
+ # but loading it requires an authorization whose resource_external_type is
19
+ # "Resource::ResourceRestartPermission" (edges to a SuperAuth::Resource
20
+ # registered with that external_type). If you can't load the object, you
21
+ # can't call the method.
2
22
  def self.included(base)
3
23
  base.send(:default_scope, **{all_queries: true}) do
4
24
  if SuperAuth.current_user.blank?
@@ -26,7 +46,10 @@ module SuperAuth::ActiveRecord::ByCurrentUser
26
46
  if type_level.exists?
27
47
  self
28
48
  else
29
- # Per-record authorization: filter to specific records the user can access.
49
+ # Per-record authorization: filter to specific records the user can
50
+ # access. No type handling here: the external id columns are created
51
+ # with the app's pk type (SuperAuth.external_id_type at install
52
+ # time), so the comparison is natively typed.
30
53
  where(
31
54
  id: SuperAuth::ActiveRecord::Authorization
32
55
  .where(**user_where, resource_external_type: resource_type)
@@ -36,7 +59,4 @@ module SuperAuth::ActiveRecord::ByCurrentUser
36
59
  end
37
60
  end
38
61
  end
39
-
40
- module ClassMethods
41
- end
42
62
  end
@@ -8,6 +8,11 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
8
8
  many_to_one :resource
9
9
 
10
10
  class << self
11
+ # The five strategies are UNIONed positionally. A column that is a real
12
+ # text column in one strategy and CAST(NULL AS ...) in another must be cast
13
+ # in every strategy: on MySQL the table collation and the connection
14
+ # collation can differ, and a column meeting a NULL cast at the same
15
+ # coercibility raises "Illegal mix of collations for operation 'UNION'".
11
16
  def string_cast_type
12
17
  case SuperAuth.db.database_type
13
18
  when :mysql, :mysql2
@@ -36,44 +41,24 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
36
41
 
37
42
  def users_groups_roles_permissions_resources
38
43
  cast_type = string_cast_type
39
- users_groups_roles_ds = SuperAuth::User.join(:super_auth_edges, user_id: :id).
40
- select_all(:super_auth_users).
41
- join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:groups), Sequel.function(:concat, ',', Sequel[:groups][:group_path], ',').like(Sequel.function(:concat, '%,', Sequel[:groups][:id], ',%'))).
42
- select(
43
- Sequel[:super_auth_users][:id].as(:user_id),
44
- Sequel[:super_auth_users][:name].as(:user_name),
45
- Sequel[:super_auth_users][:external_id].as(:user_external_id),
46
- Sequel[:super_auth_users][:external_type].as(:user_external_type),
47
- Sequel[:super_auth_users][:created_at].as(:user_created_at),
48
- Sequel[:super_auth_users][:updated_at].as(:user_updated_at),
49
- Sequel[:groups][:id].as(:group_id),
50
- Sequel[:groups][:name].as(:group_name),
51
- Sequel[:super_auth_edges][:id].as(:edge_id),
52
- Sequel[:super_auth_edges][:permission_id].as(:edge_permission_id),
53
- Sequel[:super_auth_edges][:group_id].as(:edge_group_id),
54
- Sequel[:super_auth_edges][:user_id].as(:edge_user_id),
55
- Sequel[:super_auth_edges][:role_id].as(:edge_role_id),
56
- Sequel[:groups][:group_path],
57
- Sequel[:groups][:group_name_path],
58
- Sequel[:groups][:parent_id],
59
- Sequel[:groups][:created_at].cast(cast_type).as(:group_created_at),
60
- Sequel[:groups][:updated_at].cast(cast_type).as(:group_updated_at),
61
- ).join(Sequel[:super_auth_edges].as(:group_role_edges), Sequel[:group_role_edges][:group_id] => Sequel[:groups][:id]).select_append(
62
- Sequel[:group_role_edges][:id].as(:group_role_edge_id),
63
- Sequel[:group_role_edges][:permission_id].as(:group_role_edge_permission_id),
64
- Sequel[:group_role_edges][:group_id].as(:group_role_edge_group_id),
65
- Sequel[:group_role_edges][:user_id].as(:group_role_edge_user_id),
66
- Sequel[:group_role_edges][:role_id].as(:group_role_edge_role_id),
67
- ).join(:super_auth_roles, id: Sequel[:group_role_edges][:role_id])
68
-
69
- SuperAuth::Edge.from(
70
- SuperAuth::Edge.from(
71
- SuperAuth::Group.cte(SuperAuth::Group.where(id: users_groups_roles_ds.select(Sequel[:groups][:id])).select(:id)).select { [id.as(:group_id), name.as(:group_name), parent_id.as(:group_parent_id), group_path, group_name_path, created_at.cast(cast_type).as(:group_created_at), updated_at.as(:group_updated_at)] },
72
- SuperAuth::Role.cte(users_groups_roles_ds.select(Sequel[:group_role_edges][:role_id])).select { [id.as(:role_id), name.as(:role_name), parent_id.as(:role_parent_id), role_path, role_name_path, created_at.as(:role_created_at), updated_at.as(:role_updated_at) ] }
73
- ).as(:users_groups_roles_permissions_resources)
74
- ).join(Sequel[:super_auth_edges].as(:user_edges), Sequel[:user_edges][:group_id] => Sequel[:users_groups_roles_permissions_resources][:group_id])
75
- .join(Sequel[:super_auth_users], id: Sequel[:user_edges][:user_id])
76
- .select(
44
+ # Join users to their group via edges. group_ancestors pairs that group with itself and
45
+ # every ancestor, so a group -> role edge on any of them applies. role_descendants then
46
+ # expands the granted role to its whole subtree. Each step is correlated to the previous
47
+ # one, so a role held by one group never reaches members of an unrelated group. The tree
48
+ # CTEs (user_groups, granted_roles) are joined by id only to supply the path columns.
49
+ SuperAuth::User.db[:super_auth_users].
50
+ join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
51
+ join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
52
+ join(Sequel[:super_auth_edges].as(:group_role_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
53
+ where(Sequel.~(Sequel[:group_role_edges][:role_id] => nil)).
54
+ join(SuperAuth::Role.descendant_pairs.as(:role_descendants), ancestor_id: Sequel[:group_role_edges][:role_id]).
55
+ join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:user_groups), Sequel[:user_groups][:id] => Sequel[:user_edges][:group_id]).
56
+ join(SuperAuth::Role.from(SuperAuth::Role.trees).as(:granted_roles), Sequel[:granted_roles][:id] => Sequel[:role_descendants][:descendant_id]).
57
+ join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
58
+ join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
59
+ join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id]).
60
+ join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
61
+ select(
77
62
  Sequel[:super_auth_users][:id].as(:user_id),
78
63
  Sequel[:super_auth_users][:name].as(:user_name),
79
64
  Sequel[:super_auth_users][:external_id].as(:user_external_id),
@@ -81,24 +66,24 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
81
66
  Sequel[:super_auth_users][:created_at].cast(cast_type).as(:user_created_at),
82
67
  Sequel[:super_auth_users][:updated_at].cast(cast_type).as(:user_updated_at),
83
68
 
84
- Sequel[:users_groups_roles_permissions_resources][:group_id],
85
- Sequel[:users_groups_roles_permissions_resources][:group_name],
86
- Sequel[:users_groups_roles_permissions_resources][:group_path],
87
- Sequel[:users_groups_roles_permissions_resources][:group_name_path],
88
- Sequel[:users_groups_roles_permissions_resources][:group_parent_id],
89
- Sequel[:users_groups_roles_permissions_resources][:group_created_at].cast(cast_type).as(:group_created_at),
90
- Sequel[:users_groups_roles_permissions_resources][:group_updated_at].cast(cast_type).as(:group_updated_at),
69
+ Sequel[:user_groups][:id].as(:group_id),
70
+ Sequel[:user_groups][:name].cast(cast_type).as(:group_name),
71
+ Sequel[:user_groups][:group_path],
72
+ Sequel[:user_groups][:group_name_path].cast(cast_type).as(:group_name_path),
73
+ Sequel[:user_groups][:parent_id].as(:group_parent_id),
74
+ Sequel[:user_groups][:created_at].cast(cast_type).as(:group_created_at),
75
+ Sequel[:user_groups][:updated_at].cast(cast_type).as(:group_updated_at),
91
76
 
92
- Sequel[:users_groups_roles_permissions_resources][:role_id],
93
- Sequel[:users_groups_roles_permissions_resources][:role_name],
94
- Sequel[:users_groups_roles_permissions_resources][:role_path],
95
- Sequel[:users_groups_roles_permissions_resources][:role_name_path],
96
- Sequel[:users_groups_roles_permissions_resources][:role_parent_id],
97
- Sequel[:users_groups_roles_permissions_resources][:role_created_at].cast(cast_type).as(:role_created_at),
98
- Sequel[:users_groups_roles_permissions_resources][:role_updated_at].cast(cast_type).as(:role_updated_at),
77
+ Sequel[:granted_roles][:id].as(:role_id),
78
+ Sequel[:granted_roles][:name].cast(cast_type).as(:role_name),
79
+ Sequel[:granted_roles][:role_path],
80
+ Sequel[:granted_roles][:role_name_path].cast(cast_type).as(:role_name_path),
81
+ Sequel[:granted_roles][:parent_id].as(:role_parent_id),
82
+ Sequel[:granted_roles][:created_at].cast(cast_type).as(:role_created_at),
83
+ Sequel[:granted_roles][:updated_at].cast(cast_type).as(:role_updated_at),
99
84
 
100
85
  Sequel[:super_auth_permissions][:id].as(:permission_id),
101
- Sequel[:super_auth_permissions][:name].as(:permission_name),
86
+ Sequel[:super_auth_permissions][:name].cast(cast_type).as(:permission_name),
102
87
  Sequel[:super_auth_permissions][:created_at].cast(cast_type).as(:permission_created_at),
103
88
  Sequel[:super_auth_permissions][:updated_at].cast(cast_type).as(:permission_updated_at),
104
89
 
@@ -106,27 +91,20 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
106
91
  Sequel[:super_auth_resources][:name].as(:resource_name),
107
92
  Sequel[:super_auth_resources][:external_id].as(:resource_external_id),
108
93
  Sequel[:super_auth_resources][:external_type].as(:resource_external_type)
109
- )
110
- .join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:users_groups_roles_permissions_resources][:role_id])
111
- .join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id])
112
- .join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:permission_edges][:permission_id])
113
- .join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id])
114
- .distinct
94
+ ).
95
+ distinct
115
96
  end
116
97
 
117
98
  def users_groups_permissions_resources
118
99
  cast_type = string_cast_type
119
- # Join users to their group via edges, then to the group CTE to get the user's group_path.
120
- # Use group_path to find all ancestor groups (any group whose id appears in the user's group_path).
121
- # Then join permission edges on those ancestor groups.
100
+ # Join users to their group via edges. group_ancestors pairs that group with itself and
101
+ # every ancestor, so a group -> permission edge on any of them applies. user_groups (the
102
+ # tree) is joined by id only to supply the path columns.
122
103
  SuperAuth::User.db[:super_auth_users].
123
104
  join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
105
+ join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
106
+ join(Sequel[:super_auth_edges].as(:group_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
124
107
  join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:user_groups), Sequel[:user_groups][:id] => Sequel[:user_edges][:group_id]).
125
- join(Sequel[:super_auth_edges].as(:group_edges),
126
- Sequel.function(:concat, ',', Sequel[:user_groups][:group_path], ',').like(
127
- Sequel.function(:concat, '%,', Sequel[:group_edges][:group_id].cast(cast_type), ',%')
128
- )
129
- ).
130
108
  join(Sequel[:super_auth_permissions], id: Sequel[:group_edges][:permission_id]).
131
109
  join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id]).
132
110
  join(Sequel[:super_auth_resources], id: Sequel[:permission_edges][:resource_id]).
@@ -139,9 +117,9 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
139
117
  Sequel[:super_auth_users][:updated_at].cast(cast_type).as(:user_updated_at),
140
118
 
141
119
  Sequel[:user_groups][:id].as(:group_id),
142
- Sequel[:user_groups][:name].as(:group_name),
120
+ Sequel[:user_groups][:name].cast(cast_type).as(:group_name),
143
121
  Sequel[:user_groups][:group_path],
144
- Sequel[:user_groups][:group_name_path],
122
+ Sequel[:user_groups][:group_name_path].cast(cast_type).as(:group_name_path),
145
123
  Sequel[:user_groups][:parent_id].as(:group_parent_id),
146
124
  Sequel[:user_groups][:created_at].cast(cast_type).as(:group_created_at),
147
125
  Sequel[:user_groups][:updated_at].cast(cast_type).as(:group_updated_at),
@@ -155,7 +133,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
155
133
  Sequel.cast(nil, string_cast_type).as(:role_updated_at),
156
134
 
157
135
  Sequel[:super_auth_permissions][:id].as(:permission_id),
158
- Sequel[:super_auth_permissions][:name].as(:permission_name),
136
+ Sequel[:super_auth_permissions][:name].cast(cast_type).as(:permission_name),
159
137
  Sequel[:super_auth_permissions][:created_at].cast(cast_type).as(:permission_created_at),
160
138
  Sequel[:super_auth_permissions][:updated_at].cast(cast_type).as(:permission_updated_at),
161
139
 
@@ -170,26 +148,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
170
148
  def users_roles_permissions_resources
171
149
  cast_type = string_cast_type
172
150
 
173
- # Step 1: Find which roles users are directly linked to via edges
174
- user_role_ids_ds = SuperAuth::Edge.where(Sequel.~(user_id: nil) & Sequel.~(role_id: nil)).select(:role_id)
175
-
176
- # Step 2: Expand those roles to all descendants via CTE
177
- role_cte = SuperAuth::Role.cte(user_role_ids_ds).select {
178
- [id.as(:role_id), name.as(:role_name), parent_id.as(:role_parent_id), role_path, role_name_path, created_at.as(:role_created_at), updated_at.as(:role_updated_at)]
179
- }
180
-
181
- # Step 3: Build the query from the expanded role tree
182
- SuperAuth::Edge.from(role_cte.as(:users_roles_permissions_resources)).
183
- # Join user_edges — match users who link to any role in the expanded CTE
184
- # The user's edge links to an ancestor role, but the CTE path contains that ancestor
185
- # We use the role_path to check: the role_path of the CTE row starts with the user's linked role
186
- join(Sequel[:super_auth_edges].as(:user_edges),
187
- Sequel.function(:concat, ',', Sequel[:users_roles_permissions_resources][:role_path], ',').like(
188
- Sequel.function(:concat, '%,', Sequel[:user_edges][:role_id].cast(cast_type), ',%')
189
- )
190
- ).
191
- where(Sequel.~(Sequel[:user_edges][:user_id] => nil) & Sequel.~(Sequel[:user_edges][:role_id] => nil)).
192
- join(Sequel[:super_auth_users], id: Sequel[:user_edges][:user_id]).
151
+ # Join users to the roles they hold directly. role_descendants expands each held role to
152
+ # its whole subtree; granted_roles (the tree) is joined by id only to supply the path columns.
153
+ SuperAuth::User.db[:super_auth_users].
154
+ join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
155
+ where(Sequel.~(Sequel[:user_edges][:role_id] => nil)).
156
+ join(SuperAuth::Role.descendant_pairs.as(:role_descendants), ancestor_id: Sequel[:user_edges][:role_id]).
157
+ join(SuperAuth::Role.from(SuperAuth::Role.trees).as(:granted_roles), Sequel[:granted_roles][:id] => Sequel[:role_descendants][:descendant_id]).
193
158
  select(
194
159
  Sequel[:super_auth_users][:id].as(:user_id),
195
160
  Sequel[:super_auth_users][:name].as(:user_name),
@@ -206,16 +171,16 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
206
171
  Sequel.cast(nil, string_cast_type).as(:group_created_at),
207
172
  Sequel.cast(nil, string_cast_type).as(:group_updated_at),
208
173
 
209
- Sequel[:users_roles_permissions_resources][:role_id],
210
- Sequel[:users_roles_permissions_resources][:role_name],
211
- Sequel[:users_roles_permissions_resources][:role_path],
212
- Sequel[:users_roles_permissions_resources][:role_name_path],
213
- Sequel[:users_roles_permissions_resources][:role_parent_id],
214
- Sequel[:users_roles_permissions_resources][:role_created_at].cast(cast_type).as(:role_created_at),
215
- Sequel[:users_roles_permissions_resources][:role_updated_at].cast(cast_type).as(:role_updated_at),
174
+ Sequel[:granted_roles][:id].as(:role_id),
175
+ Sequel[:granted_roles][:name].cast(cast_type).as(:role_name),
176
+ Sequel[:granted_roles][:role_path],
177
+ Sequel[:granted_roles][:role_name_path].cast(cast_type).as(:role_name_path),
178
+ Sequel[:granted_roles][:parent_id].as(:role_parent_id),
179
+ Sequel[:granted_roles][:created_at].cast(cast_type).as(:role_created_at),
180
+ Sequel[:granted_roles][:updated_at].cast(cast_type).as(:role_updated_at),
216
181
 
217
182
  Sequel[:super_auth_permissions][:id].as(:permission_id),
218
- Sequel[:super_auth_permissions][:name].as(:permission_name),
183
+ Sequel[:super_auth_permissions][:name].cast(cast_type).as(:permission_name),
219
184
  Sequel[:super_auth_permissions][:created_at].cast(cast_type).as(:permission_created_at),
220
185
  Sequel[:super_auth_permissions][:updated_at].cast(cast_type).as(:permission_updated_at),
221
186
 
@@ -225,7 +190,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
225
190
  Sequel[:super_auth_resources][:external_type].as(:resource_external_type),
226
191
  ).
227
192
  # Join permission and resource edges on the expanded role
228
- join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:users_roles_permissions_resources][:role_id]).
193
+ join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
229
194
  join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
230
195
  join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id]).
231
196
  join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
@@ -261,7 +226,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
261
226
  Sequel.cast(nil, string_cast_type).as(:role_updated_at),
262
227
 
263
228
  Sequel[:super_auth_permissions][:id].as(:permission_id),
264
- Sequel[:super_auth_permissions][:name].as(:permission_name),
229
+ Sequel[:super_auth_permissions][:name].cast(cast_type).as(:permission_name),
265
230
  Sequel[:super_auth_permissions][:created_at].cast(cast_type).as(:permission_created_at),
266
231
  Sequel[:super_auth_permissions][:updated_at].cast(cast_type).as(:permission_updated_at),
267
232
 
@@ -35,6 +35,44 @@ module SuperAuth::Nestable
35
35
  end
36
36
  end
37
37
 
38
+ # Cast type for the anchor row of the path CTEs. MySQL types a recursive
39
+ # CTE's columns from the anchor SELECT alone, so a bare CAST(id AS CHAR)
40
+ # makes the path column varchar(11) and every deeper level overflows it
41
+ # ("Data too long for column"). :text is unbounded elsewhere.
42
+ def path_cast_type
43
+ case SuperAuth.db.database_type
44
+ when :mysql, :mysql2
45
+ "char(4000)"
46
+ else
47
+ :text
48
+ end
49
+ end
50
+
51
+ # Every node paired with itself and each of its ancestors, as
52
+ # (descendant_id, ancestor_id). The path strategies join these integer
53
+ # pairs on equality; matching ids inside the comma-separated path strings
54
+ # with LIKE forced a nested loop no planner could index, and compile time
55
+ # grew roughly cubically with the graph.
56
+ def ancestor_pairs
57
+ table = pluralize
58
+ name = :"#{singularize}_ancestor_pairs"
59
+ anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
60
+ step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
61
+ select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
62
+ db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
63
+ end
64
+
65
+ # Every node paired with itself and each of its descendants, as
66
+ # (ancestor_id, descendant_id). Granting a role grants its whole subtree.
67
+ def descendant_pairs
68
+ table = pluralize
69
+ name = :"#{singularize}_descendant_pairs"
70
+ anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
71
+ step = db[name].join(table, parent_id: :descendant_id).
72
+ select(Sequel[name][:ancestor_id], Sequel[table][:id])
73
+ db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id])
74
+ end
75
+
38
76
  def cte(id = nil, direction = :desc)
39
77
  model = self
40
78
  cte_name = model.cte_name
@@ -69,8 +107,8 @@ module SuperAuth::Nestable
69
107
  def with_descending_paths(base_ds, recursive_ds, cte_name)
70
108
  [
71
109
  base_ds.select_append(
72
- Sequel[table_name][:id].cast(string_cast_type).as(base_path)
73
- ).select_append(Sequel[table_name][:name].as(base_name_path)),
110
+ Sequel[table_name][:id].cast(path_cast_type).as(base_path)
111
+ ).select_append(Sequel[table_name][:name].cast(path_cast_type).as(base_name_path)),
74
112
 
75
113
  recursive_ds.select_append(
76
114
  Sequel.function(:concat,
@@ -90,7 +128,7 @@ module SuperAuth::Nestable
90
128
 
91
129
  def with_ascending_paths(base_ds, recursive_ds, cte_name)
92
130
  [
93
- base_ds.select_append(Sequel[table_name][:id].cast(string_cast_type).as(base_path)).select_append(Sequel[table_name][:name].as(base_name_path)),
131
+ base_ds.select_append(Sequel[table_name][:id].cast(path_cast_type).as(base_path)).select_append(Sequel[table_name][:name].cast(path_cast_type).as(base_name_path)),
94
132
  recursive_ds.select_append(
95
133
  Sequel.function(:concat,
96
134
  Sequel[table_name][:id].cast(string_cast_type),
@@ -0,0 +1,126 @@
1
+ # Postgres Row-Level Security enforcement.
2
+ #
3
+ # ByCurrentUser filters queries at the ORM layer; RLS enforces the same rule
4
+ # inside Postgres, so raw SQL, `unscoped`, and non-Ruby clients are subject
5
+ # to it too — enforcing apps don't load this gem at all. Identity is
6
+ # asserted per transaction by the super_auth_become() SQL function
7
+ # (installed by `enable`): it sets transaction-local identity settings plus
8
+ # a stamp of the current transaction id, and every policy requires a stamp
9
+ # from the current transaction. Identity therefore cannot outlive its
10
+ # transaction or leak across pooled connections — a query without a fresh
11
+ # assertion sees no rows.
12
+ module SuperAuth
13
+ module RLS
14
+ POLICY = "super_auth".freeze
15
+
16
+ class << self
17
+ # Enable RLS on an app table with a policy mirroring ByCurrentUser:
18
+ # type-level authorization rows (resource_external_id IS NULL) act as a
19
+ # wildcard, per-record rows match on id, and system users bypass.
20
+ #
21
+ # One deliberate divergence: INSERTs are also gated. The policy is
22
+ # FOR ALL with no WITH CHECK, so Postgres reuses its USING expression
23
+ # as the implicit WITH CHECK for new rows. Creating rows therefore
24
+ # requires a type-level authorization for the resource type, or
25
+ # system context.
26
+ def enable(table, resource_type:, db: SuperAuth.db)
27
+ postgres!(db)
28
+ create_become_function(db)
29
+ t = db.literal(Sequel.identifier(table.to_s))
30
+ db.run "ALTER TABLE #{t} ENABLE ROW LEVEL SECURITY"
31
+ # FORCE: apply the policy even when the app connects as the table owner
32
+ db.run "ALTER TABLE #{t} FORCE ROW LEVEL SECURITY"
33
+ db.run "DROP POLICY IF EXISTS #{POLICY} ON #{t}"
34
+ db.run <<~SQL
35
+ CREATE POLICY #{POLICY} ON #{t}
36
+ USING (
37
+ current_setting('super_auth.xid', true) = pg_current_xact_id()::text
38
+ AND (
39
+ COALESCE(current_setting('super_auth.system', true), '') = 'true'
40
+ OR EXISTS (
41
+ SELECT 1 FROM super_auth_authorizations a
42
+ WHERE a.resource_external_type = #{db.literal(resource_type.to_s)}
43
+ AND (a.resource_external_id IS NULL OR a.resource_external_id = #{t}.id)
44
+ AND (
45
+ a.user_id::text = NULLIF(current_setting('super_auth.user_id', true), '')
46
+ OR (
47
+ a.user_external_id::text = NULLIF(current_setting('super_auth.user_external_id', true), '')
48
+ AND a.user_external_type = NULLIF(current_setting('super_auth.user_external_type', true), '')
49
+ )
50
+ )
51
+ )
52
+ )
53
+ )
54
+ SQL
55
+ end
56
+
57
+ # Drops the table's policy; the shared super_auth_become function is
58
+ # left in place (other tables may still be protected, and it is
59
+ # harmless on its own).
60
+ def disable(table, db: SuperAuth.db)
61
+ postgres!(db)
62
+ t = db.literal(Sequel.identifier(table.to_s))
63
+ db.run "DROP POLICY IF EXISTS #{POLICY} ON #{t}"
64
+ db.run "ALTER TABLE #{t} NO FORCE ROW LEVEL SECURITY"
65
+ db.run "ALTER TABLE #{t} DISABLE ROW LEVEL SECURITY"
66
+ end
67
+
68
+ # Run the block with `user`'s identity asserted for one transaction —
69
+ # the Ruby face of the SQL contract
70
+ # (BEGIN; SELECT super_auth_become(...); queries; COMMIT). Sequel and
71
+ # ActiveRecord queries inside the block share the transaction's
72
+ # connection, so the policies see the identity; it dies with the
73
+ # transaction. In nested calls the innermost assertion wins for the
74
+ # rest of the outer transaction.
75
+ def as(user, db: SuperAuth.db)
76
+ postgres!(db)
77
+ internal_id = external_id = external_type = nil
78
+ system = user.respond_to?(:system?) && !!user.system?
79
+ if user && internal_user?(user)
80
+ internal_id = user.id.to_s
81
+ elsif user
82
+ external_id = user.id.to_s
83
+ external_type = user.class.name
84
+ end
85
+ db.transaction do
86
+ db.get(Sequel.function(:super_auth_become, external_id, external_type, internal_id, system))
87
+ yield
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # One shared function per database; clients assert identity by calling
94
+ # it inside their transaction. CREATE OR REPLACE keeps enable
95
+ # idempotent.
96
+ def create_become_function(db)
97
+ db.run <<~SQL
98
+ CREATE OR REPLACE FUNCTION super_auth_become(
99
+ user_external_id text DEFAULT NULL,
100
+ user_external_type text DEFAULT NULL,
101
+ user_id text DEFAULT NULL,
102
+ system boolean DEFAULT false
103
+ ) RETURNS void LANGUAGE plpgsql AS $$
104
+ BEGIN
105
+ PERFORM set_config('super_auth.user_id', COALESCE(user_id, ''), true),
106
+ set_config('super_auth.user_external_id', COALESCE(user_external_id, ''), true),
107
+ set_config('super_auth.user_external_type', COALESCE(user_external_type, ''), true),
108
+ set_config('super_auth.system', CASE WHEN system THEN 'true' ELSE '' END, true),
109
+ set_config('super_auth.xid', pg_current_xact_id()::text, true);
110
+ END
111
+ $$;
112
+ SQL
113
+ end
114
+
115
+ def internal_user?(user)
116
+ (defined?(SuperAuth::ActiveRecord::User) && user.is_a?(SuperAuth::ActiveRecord::User)) ||
117
+ (defined?(SuperAuth::User) && user.is_a?(SuperAuth::User))
118
+ end
119
+
120
+ def postgres!(db)
121
+ return if db.database_type == :postgres
122
+ raise SuperAuth::Error, "SuperAuth::RLS requires Postgres (got #{db.database_type})"
123
+ end
124
+ end
125
+ end
126
+ end
@@ -1,3 +1,3 @@
1
1
  module SuperAuth
2
- VERSION = "0.3.3"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/super_auth.rb CHANGED
@@ -22,6 +22,26 @@ module SuperAuth
22
22
  @missing_user_behavior = behavior
23
23
  end
24
24
 
25
+ # Column type for the external id columns (users.external_id,
26
+ # resources.external_id and their copies on authorizations). Set it to your
27
+ # application's primary key type (:bigint, :uuid, :string, ...) BEFORE
28
+ # running the super_auth migrations — the columns are then created with the
29
+ # matching type and every comparison against your tables' pks is natively
30
+ # typed, with no casting anywhere. Default :string.
31
+ def self.external_id_type
32
+ @external_id_type || :string
33
+ end
34
+
35
+ def self.external_id_type=(type)
36
+ @external_id_type = type
37
+ end
38
+
39
+ # Sequel migrations take the Ruby String class for varchar; anything else
40
+ # passes through as the literal database type name.
41
+ def self.sequel_external_id_type
42
+ external_id_type == :string ? String : external_id_type
43
+ end
44
+
25
45
  def self.load
26
46
  require "super_auth/authorization"
27
47
  require "super_auth/edge"
@@ -30,6 +50,7 @@ module SuperAuth
30
50
  require "super_auth/permission"
31
51
  require "super_auth/railtie"
32
52
  require "super_auth/resource"
53
+ require "super_auth/rls"
33
54
  require "super_auth/role"
34
55
  require "super_auth/user"
35
56
  require "super_auth/active_record" if defined?(ActiveRecord::Base)
@@ -40,6 +61,25 @@ module SuperAuth
40
61
  require "pathname"
41
62
  path = Pathname.new(__FILE__).parent.parent.join("db", "migrate")
42
63
  Sequel::Migrator.run(SuperAuth.db, path)
64
+ refresh_model_schemas
65
+ end
66
+
67
+ # Both ORMs cache column types per model class; after (re)installing the
68
+ # migrations those caches can describe a previous schema (e.g. a different
69
+ # external_id_type) and silently miscast assigned values.
70
+ def self.refresh_model_schemas
71
+ models = %w[User Group Permission Role Resource Edge Authorization]
72
+ if defined?(SuperAuth::ActiveRecord::User)
73
+ models.each do |name|
74
+ SuperAuth::ActiveRecord.const_get(name).reset_column_information
75
+ end
76
+ end
77
+ if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
78
+ models.each do |name|
79
+ model = SuperAuth.const_get(name)
80
+ model.set_dataset(model.dataset)
81
+ end
82
+ end
43
83
  end
44
84
 
45
85
  def self.uninstall_migrations
@@ -55,6 +95,13 @@ module SuperAuth
55
95
  raise Error, "Failed to uninstall migrations: #{e.message}"
56
96
  end
57
97
 
98
+ # Run the block with `user`'s identity asserted inside a database
99
+ # transaction, so RLS policies (see SuperAuth::RLS) enforce authorization
100
+ # for every query in the block. Postgres only.
101
+ def self.as(user, db: SuperAuth.db, &block)
102
+ SuperAuth::RLS.as(user, db: db, &block)
103
+ end
104
+
58
105
  def self.current_user=(user)
59
106
  Thread.current[:super_auth_current_user] = user
60
107
  end
@@ -128,4 +175,5 @@ module SuperAuth
128
175
  end
129
176
  end
130
177
 
178
+ require "super_auth/rls"
131
179
  require "super_auth/railtie" if defined?(Rails::Railtie)
data/super_auth.gemspec CHANGED
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.summary = "Make Unauthenticated State Unrepresentable"
10
10
  spec.description = "Simple, yet super powerful authorization for you application"
11
11
  spec.homepage = "https://github.com/JonathanFrias/super_auth"
12
- spec.license = "MIT"
12
+ spec.license = "GPL-2.0"
13
13
  spec.required_ruby_version = ">= 2.6.0"
14
14
 
15
15
  spec.metadata["homepage_uri"] = spec.homepage
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.3.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jonathan Frias
@@ -79,6 +79,8 @@ files:
79
79
  - lib/generators/super_auth/install/install_generator.rb
80
80
  - lib/generators/super_auth/install/templates/README
81
81
  - lib/generators/super_auth/install/templates/super_auth.rb
82
+ - lib/generators/super_auth/rls/rls_generator.rb
83
+ - lib/generators/super_auth/rls/templates/migration.rb.erb
82
84
  - lib/super_auth.rb
83
85
  - lib/super_auth/active_record.rb
84
86
  - lib/super_auth/active_record/authorization.rb
@@ -96,6 +98,7 @@ files:
96
98
  - lib/super_auth/permission.rb
97
99
  - lib/super_auth/railtie.rb
98
100
  - lib/super_auth/resource.rb
101
+ - lib/super_auth/rls.rb
99
102
  - lib/super_auth/role.rb
100
103
  - lib/super_auth/user.rb
101
104
  - lib/super_auth/version.rb
@@ -104,7 +107,7 @@ files:
104
107
  - visualization.html
105
108
  homepage: https://github.com/JonathanFrias/super_auth
106
109
  licenses:
107
- - MIT
110
+ - GPL-2.0
108
111
  metadata:
109
112
  homepage_uri: https://github.com/JonathanFrias/super_auth
110
113
  source_code_uri: https://github.com/JonathanFrias/super_auth