super_auth 0.3.2 → 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 +4 -4
- data/CHANGELOG.md +25 -0
- data/Gemfile.lock +1 -1
- data/README.md +203 -5
- data/USAGE.md +24 -1
- data/db/migrate/1_users.rb +1 -1
- data/db/migrate/5_resources.rb +1 -1
- data/db/migrate/7_authorization.rb +2 -2
- data/db/migrate/8_add_indexes_to_edges.rb +7 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +16 -5
- data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +13 -0
- data/lib/super_auth/active_record/by_current_user.rb +24 -4
- data/lib/super_auth/edge.rb +65 -100
- data/lib/super_auth/nestable.rb +41 -3
- data/lib/super_auth/rls.rb +126 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +54 -4
- data/super_auth.gemspec +35 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d15415f81b29b08a690f0223fe01fd888177085f479adb902ab5103002e7292
|
|
4
|
+
data.tar.gz: 6bc43afccfc699cbf2f8d67c6af32226cfa8989f2e188bbe919835529b488a43
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8fc62a59a8291668b01d3a4a012422f764302534f5acc7be6d5954e2dfa4427cd69d7b5f81d65fb96a9be77f1c3c328ce4785071ba2310e7c2cbecf1c1174f9f
|
|
7
|
+
data.tar.gz: c5012e5e5fdebb90c46326ed8438e939b4c37562730aa33078eecc9dd74debb07c0154509c90549c4dbc783aa4f14e7d2353e63d814571e22e018f45439d3991
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
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
|
+
|
|
24
|
+
## [0.3.3] - 2026-04-29
|
|
25
|
+
|
|
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.
|
|
27
|
+
|
|
3
28
|
## [0.3.2] - 2026-03-10
|
|
4
29
|
|
|
5
30
|
- Feature: Add `SuperAuth.missing_user_behavior` configuration option
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/JonathanFrias/super_auth/actions)
|
|
4
4
|
|
|
5
|
-
Super auth is turn-key authorization
|
|
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
|
|
8
|
-
access control is the NUMBER 1 most common security risk in modern applications today. super_auth provides
|
|
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
|
-
|
|
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/
|
|
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/
|
|
642
|
+
SuperAuth is available as open source under the [GPL v2 License](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html).
|
data/db/migrate/1_users.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/db/migrate/5_resources.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
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
|
data/lib/super_auth/edge.rb
CHANGED
|
@@ -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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Sequel[:
|
|
46
|
-
Sequel[:
|
|
47
|
-
Sequel[:
|
|
48
|
-
Sequel[:
|
|
49
|
-
Sequel[:
|
|
50
|
-
Sequel[:
|
|
51
|
-
Sequel[:
|
|
52
|
-
Sequel[:super_auth_edges]
|
|
53
|
-
Sequel[:
|
|
54
|
-
Sequel[:super_auth_edges]
|
|
55
|
-
Sequel[:
|
|
56
|
-
|
|
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[:
|
|
85
|
-
Sequel[:
|
|
86
|
-
Sequel[:
|
|
87
|
-
Sequel[:
|
|
88
|
-
Sequel[:
|
|
89
|
-
Sequel[:
|
|
90
|
-
Sequel[:
|
|
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[:
|
|
93
|
-
Sequel[:
|
|
94
|
-
Sequel[:
|
|
95
|
-
Sequel[:
|
|
96
|
-
Sequel[:
|
|
97
|
-
Sequel[:
|
|
98
|
-
Sequel[:
|
|
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
|
-
|
|
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
|
|
120
|
-
#
|
|
121
|
-
#
|
|
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
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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[:
|
|
210
|
-
Sequel[:
|
|
211
|
-
Sequel[:
|
|
212
|
-
Sequel[:
|
|
213
|
-
Sequel[:
|
|
214
|
-
Sequel[:
|
|
215
|
-
Sequel[:
|
|
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[:
|
|
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
|
|
data/lib/super_auth/nestable.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
data/lib/super_auth/version.rb
CHANGED
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
|
|
@@ -86,12 +133,14 @@ module SuperAuth
|
|
|
86
133
|
::ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
|
|
87
134
|
end
|
|
88
135
|
|
|
89
|
-
|
|
90
|
-
|
|
136
|
+
# Walk the ancestor chain so adapter subclasses (e.g. PostGIS, Makara)
|
|
137
|
+
# are recognized as their parent adapter type.
|
|
138
|
+
adapter_ancestors = ::ActiveRecord::Base.adapter_class.ancestors.map(&:to_s)
|
|
139
|
+
if adapter_ancestors.include?("ActiveRecord::ConnectionAdapters::SQLite3Adapter")
|
|
91
140
|
SuperAuth.db = Sequel.sqlite(**extensions)
|
|
92
|
-
|
|
141
|
+
elsif adapter_ancestors.include?("ActiveRecord::ConnectionAdapters::PostgreSQLAdapter")
|
|
93
142
|
SuperAuth.db = Sequel.postgres(**extensions)
|
|
94
|
-
|
|
143
|
+
elsif adapter_ancestors.include?("ActiveRecord::ConnectionAdapters::Mysql2Adapter")
|
|
95
144
|
SuperAuth.db = Sequel.mysql2(**extensions)
|
|
96
145
|
else
|
|
97
146
|
warn "[SuperAuth] WARNING: Unknown adapter: #{::ActiveRecord::Base.adapter_class}"
|
|
@@ -126,4 +175,5 @@ module SuperAuth
|
|
|
126
175
|
end
|
|
127
176
|
end
|
|
128
177
|
|
|
178
|
+
require "super_auth/rls"
|
|
129
179
|
require "super_auth/railtie" if defined?(Rails::Railtie)
|
data/super_auth.gemspec
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require_relative "lib/super_auth/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "super_auth"
|
|
5
|
+
spec.version = SuperAuth::VERSION
|
|
6
|
+
spec.authors = ["Jonathan Frias"]
|
|
7
|
+
spec.email = ["jonathan@gofrias.com"]
|
|
8
|
+
|
|
9
|
+
spec.summary = "Make Unauthenticated State Unrepresentable"
|
|
10
|
+
spec.description = "Simple, yet super powerful authorization for you application"
|
|
11
|
+
spec.homepage = "https://github.com/JonathanFrias/super_auth"
|
|
12
|
+
spec.license = "GPL-2.0"
|
|
13
|
+
spec.required_ruby_version = ">= 2.6.0"
|
|
14
|
+
|
|
15
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
16
|
+
spec.metadata["source_code_uri"] = "https://github.com/JonathanFrias/super_auth"
|
|
17
|
+
spec.metadata["changelog_uri"] = "https://github.com/JonathanFrias/super_auth/blob/main/CHANGELOG.md"
|
|
18
|
+
|
|
19
|
+
# Specify which files should be added to the gem when it is released.
|
|
20
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
21
|
+
spec.files = Dir.chdir(__dir__) do
|
|
22
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
23
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|circleci)|appveyor)})
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
spec.bindir = "bin"
|
|
27
|
+
spec.executables = spec.files.grep(%r{\Abin/}) { |f| File.basename(f) }
|
|
28
|
+
spec.require_paths = ["lib"]
|
|
29
|
+
|
|
30
|
+
# Uncomment to register a new dependency of your gem
|
|
31
|
+
spec.add_dependency "sequel"
|
|
32
|
+
spec.add_development_dependency "sqlite3"
|
|
33
|
+
# For more information and examples about making a new gem, check out our
|
|
34
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
35
|
+
end
|
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.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,14 +98,16 @@ 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
|
|
102
105
|
- lib/tasks/super_auth_tasks.rake
|
|
106
|
+
- super_auth.gemspec
|
|
103
107
|
- visualization.html
|
|
104
108
|
homepage: https://github.com/JonathanFrias/super_auth
|
|
105
109
|
licenses:
|
|
106
|
-
-
|
|
110
|
+
- GPL-2.0
|
|
107
111
|
metadata:
|
|
108
112
|
homepage_uri: https://github.com/JonathanFrias/super_auth
|
|
109
113
|
source_code_uri: https://github.com/JonathanFrias/super_auth
|