super_auth 0.7.0 → 0.8.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 +33 -0
- data/Gemfile.lock +1 -1
- data/README.md +39 -22
- data/USAGE.md +84 -19
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/seeds/sample_data.rb +1 -0
- data/lib/generators/super_auth/install/templates/README +4 -2
- data/lib/super_auth/active_record/authorization.rb +7 -0
- data/lib/super_auth/active_record/resource.rb +8 -4
- data/lib/super_auth/authorization.rb +13 -1
- data/lib/super_auth/edge.rb +54 -18
- data/lib/super_auth/editor/index.html +17 -10
- data/lib/super_auth/editor/seed.rb +11 -5
- data/lib/super_auth/editor.rb +24 -9
- data/lib/super_auth/nestable.rb +16 -3
- data/lib/super_auth/railtie.rb +9 -0
- data/lib/super_auth/resource.rb +57 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +31 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f41f8bbae64f5e5ddf3666026d00a64cbe4007037b2c859152b7b3c2e331023
|
|
4
|
+
data.tar.gz: 6e8b002d7a9e23787565e85494c23b1a1f912770ad800b7a1986a23188701d6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bde433eba068b2b55edad37701dac014340a5e840e26322238760d554f1e44e8aabc31d934ed776207f02439563ffb52083195cbf99207807ac8e0b23dbf7b5a
|
|
7
|
+
data.tar.gz: eb3456e6855c070931b5488fd8632eb60d74305753ea007d346f47a11f257aec2104d83061dafdea09f43ab7e90cddfaf4aaf69be236010f2f0896a3821242b0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `super_auth_resources.parent_id`, a nullable self-referencing key, so resource nodes nest the way groups and roles do. `SuperAuth::Resource` includes `SuperAuth::Nestable` and gains the same tree API: `parent`, `children`, `ancestors`, `descendants` (and the `_dataset` variants), `roots`, `trees`, `ancestor_pairs` and `descendant_pairs`. A node with neither `external_type` nor `external_id` is a container: one per folder, project, tenant or whatever the application nests records under, with the records registered beneath it. Migration 11 in both `db/migrate/` and `db/migrate_activerecord/`.
|
|
8
|
+
- A grant on a resource node reaches the node and every node under it, through all five path strategies. The last hop of each strategy is a join through `SuperAuth::Resource.descendant_pairs` where it was a primary-key join, so the subtree is expanded at compile time and `super_auth_authorizations` gains no columns: `ByCurrentUser` and the row-level security policy are untouched, still read only `resource_external_type` and `resource_external_id` from the compiled table, and cannot tell a row compiled through a container from one granted directly. No recompile is needed after upgrading. On a flat graph the pairs relation is the identity and the compiled rows are exactly what 0.7.0 produced.
|
|
9
|
+
- The pairs CTE is anchored on the resource ids that appear in `super_auth_edges.resource_id` (`descendant_pairs(of:)`, wrapped by `SuperAuth::Edge.resource_subtrees`), so the walk is sized by the grants rather than by the table. Groups and roles are few and the whole table is cheap; resources are one row per protected record, and an unanchored CTE materialises every pair of the whole table once per strategy that joins it. Measured at 300,000 resources: MySQL 8 went from 27 s per strategy unanchored to 0.025 s anchored, Postgres from 0.3–0.6 s to 0.008 s.
|
|
10
|
+
- No `resource_path` or `resource_name_path` columns, a deliberate asymmetry with groups and roles. A row compiled through a container carries the descendant node's own `resource_id`, `resource_name`, `resource_external_type` and `resource_external_id` — the record the grant reaches, which is all runtime reads. "Granted through which container" is answered by the graph (`parent_id`, `parent`, `children`) and by the editor, not by the compiled table; the trade is that an audit of the compiled table alone no longer sees the container an edge was drawn to.
|
|
11
|
+
- Containment is not inheritance. Because the compiled row copies the descendant's own `external_type`, the permission-gated subclass rule holds — a `"Post"` grant still says nothing about `"Post::PostPublishPermission"` — but a capability node registered *under* its base-class node is a descendant of it and receives every grant on the base. Register capability nodes as siblings of their base-class nodes, or in a container beside them, never as their children. A documentation rule, not a check: the gem cannot tell a capability subclass from any other type name.
|
|
12
|
+
- The editor renders resources as a tree, creates containers (a resource node with no external link, at the root or under a chosen parent), and refuses two things with a 422 and the reason: creating a node under a wildcard parent, and a compile refused by the guard below (in 0.7.0 a resource create with any `parent_id` was refused as "resource records cannot have a parent"; that message no longer occurs for resources). Application code registers records under a container by saving a node with `parent_id`; there is no re-parent route yet.
|
|
13
|
+
- `SuperAuth.deprecator`, where the gem's deprecation warnings go. An `ActiveSupport::Deprecation` when ActiveSupport is loaded, which the railtie registers as `app.deprecators[:super_auth]` so `config.active_support.deprecation` and `report_deprecations` apply on Rails 7.1+; otherwise a stand-in with the same `warn` and `silenced=`.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- The editor's second Users box. The bottom row drew Resources beside a duplicate of the Users box from the top row — same rows, shared selection — with nothing saying why; Resources now spans the bottom row and users are listed once.
|
|
18
|
+
|
|
19
|
+
### Deprecated
|
|
20
|
+
|
|
21
|
+
- Type-level (wildcard) resource nodes: a node with an `external_type` and no `external_id`, which at runtime means every record of that type, present and future (`ByCurrentUser`'s type-level branch; the policy's `resource_external_id IS NULL OR` clause). They keep working, unchanged, and no removal version is promised. What changes is at compile time. `compile!` (both twins) now warns once per compile through `SuperAuth.deprecator`, naming up to ten of them, and refuses a wildcard that has a parent or children, raising `SuperAuth::Error` ("Wildcard resource nodes must be flat, but wildcard node(s) 12, 15 have a parent or children. …") before the compiled table is touched, so the previous rows stay. That is the one shape the tree cannot hold: nested under a container, a wildcard would compile to a `(type, NULL)` row reachable through every ancestor's grants, one edge to a container silently granting every record of a type; and nodes under a wildcard are unreachable except through a grant that already covers them. The rule is that wildcard nodes are flat: at the root, with no children, or given an `external_id`. The guard and the compile are two statements, so the subtree join carries the same rule as a predicate: a `(type, NULL)` row is only ever the node a grant named, never one reached through a parent, whatever a concurrent write commits between the two under `READ COMMITTED`. The guard is the loud error; the predicate is the guarantee.
|
|
22
|
+
- A container is not a replacement for a wildcard on a table under row-level security, and the wildcard remains the only way to authorize INSERT there this release. The policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses `USING` for new rows, and a per-record row can only match an id the application has already registered and compiled. Moving an RLS-protected type from a wildcard to a container loses INSERT, needs a resource node saved and a full recompile for every new record before it is visible, and needs a role that can write the gem's tables, which `enable` grants `SELECT` on only. The successor is a grant on a parent record — `SuperAuth::RLS.enable(:documents, resource_type: "Document", parent: { column: :folder_id, resource_type: "Folder" })`, with the policy matching per record on `t.id` or per record on the parent column, and a `ByCurrentUser` mirror — planned for the next release, which is when the wildcard stops being needed. Until then a wildcard on an RLS host is the supported shape and the warning is a notice, not a fault.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `compile!` turns Postgres JIT off for its own transaction (`SET LOCAL jit = off`). Postgres was JIT-compiling the five-strategy union's expressions on every compile — 358 LLVM functions in 0.7.0, 539 with the new subtree joins — spending 1.6 s (0.7.0) to 2.2 s (0.8.0) in LLVM optimisation and emission for a query that executes in about 10 ms; the whole of the "compile is slow on Postgres" cost was JIT, on any graph size. `SET LOCAL` dies with the transaction, so nothing reaches the pooled connection. `SuperAuth::Edge.authorizations` called on its own is unchanged.
|
|
27
|
+
- `compile!` no longer runs forever on a `parent_id` cycle. The two pair CTEs in `SuperAuth::Nestable` (`ancestor_pairs`, `descendant_pairs`) recurse with `UNION` instead of `UNION ALL`: the pair relation is finite, so `UNION` stops the first time a step produces nothing new, which on a cycle is the first time round, where `UNION ALL` re-derived the same pairs without end. MySQL aborted after 1001 iterations (`cte_max_recursion_depth`); Postgres and SQLite looped until killed. Output is unchanged on a valid tree, where no step repeats a pair. The path-building tree CTEs the strategies join for group and role path columns are anchored on the roots and walk downward, so they never enter a cycle: a cyclic group or role component is unreachable from any root and every grant that passes through it compiles to no rows, silently, fail-closed. A check against cycles at write time remains open.
|
|
28
|
+
|
|
29
|
+
### Upgrade notes
|
|
30
|
+
|
|
31
|
+
- Run migration 11 before deploying code that saves resource nodes or compiles: `SuperAuth::Resource` now reads `parent_id`, so `compile!` on an un-migrated schema fails with a missing-column error. In Rails, `rails railties:install:migrations` (or the app's equivalent) copies the new migration into the app and `rails db:migrate` runs it — the engine points the install task at `db/migrate_activerecord` but does not run its migrations on its own, which the install generator's notes used to claim and now do not. Without Rails, `SuperAuth.install_migrations` or `super_auth-editor --migrate`; `rake super_auth:migrate` exists only inside a Rails app (its tasks depend on `:environment`).
|
|
32
|
+
- No recompile: the compiled table has the same columns and, on a flat graph, the same rows.
|
|
33
|
+
- With wildcard nodes in the graph, every compile prints one deprecation line naming them. Silence it with the Rails deprecation config — the deprecator is `app.deprecators[:super_auth]`, so `config.active_support.deprecation = :silence` and `config.active_support.report_deprecations = false` both apply — or with `SuperAuth.deprecator.silenced = true`.
|
|
34
|
+
- Compiled-table growth: a grant on a container compiles one row per descendant per path, and `compile!` inserts row by row, so a container over many records lengthens every compile in proportion. A single `INSERT ... SELECT` is the tracked follow-up.
|
|
35
|
+
|
|
3
36
|
## [0.7.0] - 2026-09-07
|
|
4
37
|
|
|
5
38
|
### Fixed
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -16,8 +16,8 @@ SuperAuth enforces authorization in the database, so any language can participat
|
|
|
16
16
|
|
|
17
17
|
## Supported databases
|
|
18
18
|
|
|
19
|
-
PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group and
|
|
20
|
-
CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
|
|
19
|
+
PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group, role and resource trees are
|
|
20
|
+
recursive CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
|
|
21
21
|
suite against each of the three. Row-level security is Postgres only.
|
|
22
22
|
|
|
23
23
|
## Docs
|
|
@@ -28,9 +28,13 @@ How `super_auth` stacks up against other authentication strategies:
|
|
|
28
28
|
## Graph editor
|
|
29
29
|
|
|
30
30
|
A Rails-free editor for the authorization graph: five boxes (groups, roles, users,
|
|
31
|
-
permissions, resources)
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
permissions, resources), of which groups, roles and resources are drawn as trees; click
|
|
32
|
+
any record to trace what it can reach and what reaches it; connect two records to draw
|
|
33
|
+
an edge; create records, including a resource container under a chosen parent; delete
|
|
34
|
+
records and edges; recompile. The editor makes containers, not application records:
|
|
35
|
+
your code registers a record under a container by saving a resource node with
|
|
36
|
+
`parent_id`, and a grant on the container reaches everything under it. It ships in the
|
|
37
|
+
gem as a Rack app and a command.
|
|
34
38
|
|
|
35
39
|
```bash
|
|
36
40
|
gem install super_auth rackup webrick # any Rack server works; puma too
|
|
@@ -216,7 +220,8 @@ identity in the current transaction and nothing else, and `SuperAuth::RLS.instal
|
|
|
216
220
|
reports whether `enable` has run, so no application needs to know the SQL functions'
|
|
217
221
|
signatures. Non-Ruby apps use the SQL contract directly. Each policy checks `super_auth_authorizations` with the same
|
|
218
222
|
semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
|
|
219
|
-
act as a wildcard, per-record authorizations match
|
|
223
|
+
act as a wildcard (deprecated, see CHANGELOG 0.8.0), per-record authorizations match
|
|
224
|
+
on id. Any object with an `id`
|
|
220
225
|
works as the user, including SuperAuth's own user records. For a user whose `system?`
|
|
221
226
|
is true, `SuperAuth.as` calls `super_auth_system()` instead, so the connection's role
|
|
222
227
|
must have been given the bypass with `SuperAuth::RLS.grant_system`.
|
|
@@ -228,7 +233,11 @@ must have been given the bypass with `SuperAuth::RLS.grant_system`.
|
|
|
228
233
|
reach protected rows.
|
|
229
234
|
- Creating rows requires a type-level authorization for that resource type (or system
|
|
230
235
|
context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
|
|
231
|
-
`USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs.
|
|
236
|
+
`USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs. Type-level
|
|
237
|
+
nodes are deprecated (see CHANGELOG 0.8.0) but remain the only way to authorize
|
|
238
|
+
INSERT here until the parent-record grant planned for the next release; a resource
|
|
239
|
+
container does not replace one on a protected table, because a per-record row can
|
|
240
|
+
only match an id that already exists.
|
|
232
241
|
- The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
|
|
233
242
|
id even to read-only transactions — one extra xid per protected transaction.
|
|
234
243
|
Negligible for almost everyone; revisit with a virtual-xid variant only if
|
|
@@ -273,16 +282,16 @@ The basis for how this works is that the rules engine is trying to match a user
|
|
|
273
282
|
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.
|
|
274
283
|
|
|
275
284
|
+---+ +---+
|
|
276
|
-
| | | | (Group
|
|
277
|
-
| v | v
|
|
285
|
+
| | | | (Group, Role and Resource
|
|
286
|
+
| v | v each nest within themselves)
|
|
278
287
|
+-------+ +------+
|
|
279
288
|
| Group |<----->| Role |
|
|
280
289
|
+-------+\ / +------+
|
|
281
290
|
^ \ / ^
|
|
282
291
|
| \/ |
|
|
283
|
-
| /\ |
|
|
284
|
-
| / \ |
|
|
285
|
-
V / \ V
|
|
292
|
+
| /\ | +---+
|
|
293
|
+
| / \ | | |
|
|
294
|
+
V / \ V | v
|
|
286
295
|
+---------------+ +------+/ \+------------+ +----------+ +-------------------+
|
|
287
296
|
| YourApp::User |<-->| User |<------>| Permission |<-->| Resource | <--> | YourApp::Resource |
|
|
288
297
|
+---------------+ +------+ +------------+ +----------+ +-------------------+
|
|
@@ -292,9 +301,10 @@ The engine determines if it can find an authorization route betewen a user and a
|
|
|
292
301
|
|
|
293
302
|
|
|
294
303
|
The lines between the boxes are called [edges](https://en.wikipedia.org/wiki/Glossary_of_graph_theory#edge).
|
|
295
|
-
The self-loops on `Group` and `
|
|
296
|
-
child `Group`s
|
|
297
|
-
|
|
304
|
+
The self-loops on `Group`, `Role` and `Resource` mean each nests within itself: a `Group`
|
|
305
|
+
can contain child `Group`s, a `Role` child `Role`s, and a `Resource` child `Resource`s (a
|
|
306
|
+
container with your records registered under it), recursively. Grants on a parent flow to
|
|
307
|
+
every descendant — which is why `Group`, `Role` and `Resource` are described as *trees*.
|
|
298
308
|
|
|
299
309
|
In general the super_auth has 5 different pathing strategies to search for access.
|
|
300
310
|
|
|
@@ -305,7 +315,7 @@ In general the super_auth has 5 different pathing strategies to search for acces
|
|
|
305
315
|
5. users <-> resource
|
|
306
316
|
|
|
307
317
|
Edges can be drawn between any 2 objects, allowing super_auth can seamlessly scale in complexity with you.
|
|
308
|
-
When `Group` and `
|
|
318
|
+
When `Group`, `Role` and `Resource` nodes are nested, the rules apply to all descendants. If there are any edges
|
|
309
319
|
between the specified user and the resource, then access is granted.
|
|
310
320
|
|
|
311
321
|
|
|
@@ -430,13 +440,18 @@ class Resource < ApplicationRecord
|
|
|
430
440
|
end
|
|
431
441
|
```
|
|
432
442
|
|
|
433
|
-
Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it:
|
|
443
|
+
Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it. Here the nodes sit in a container, so one edge covers every server registered under it:
|
|
434
444
|
|
|
435
445
|
```ruby
|
|
436
|
-
restartable = SuperAuth::Resource.create(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
446
|
+
restartable = SuperAuth::Resource.create(name: "restartable servers") # a container
|
|
447
|
+
servers.each do |server|
|
|
448
|
+
SuperAuth::Resource.create(
|
|
449
|
+
name: server.name,
|
|
450
|
+
external_type: "Resource::ResourceRestartPermission",
|
|
451
|
+
external_id: server.id,
|
|
452
|
+
parent: restartable
|
|
453
|
+
)
|
|
454
|
+
end
|
|
440
455
|
restart = SuperAuth::Permission.create(name: "restart")
|
|
441
456
|
SuperAuth::Edge.create(user: sa_user, permission: restart)
|
|
442
457
|
SuperAuth::Edge.create(permission: restart, resource: restartable)
|
|
@@ -448,6 +463,8 @@ Resource::ResourceRestartPermission.find(id) # needs its own explicit approval
|
|
|
448
463
|
|
|
449
464
|
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.
|
|
450
465
|
|
|
466
|
+
The resource tree is containment, not inheritance. A row compiled through a container copies the descendant node's own `external_type`, so nesting does not weaken the rule above; what weakens it is the node's position. A `"Resource::ResourceRestartPermission"` node whose parent is the `"Resource"` node is a descendant of it and receives every grant drawn on `"Resource"`. Register capability nodes as siblings of their base-class nodes, or in a container beside them as above, never as their children.
|
|
467
|
+
|
|
451
468
|
## Row-Level Security for permission-gated models
|
|
452
469
|
|
|
453
470
|
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):
|
|
@@ -465,7 +482,7 @@ end
|
|
|
465
482
|
# outside the block there is no asserted identity, so the policy matches nothing
|
|
466
483
|
```
|
|
467
484
|
|
|
468
|
-
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
|
|
485
|
+
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`, deprecated — see CHANGELOG 0.8.0) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
|
|
469
486
|
|
|
470
487
|
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.
|
|
471
488
|
|
data/USAGE.md
CHANGED
|
@@ -62,7 +62,7 @@ rails railties:install:migrations
|
|
|
62
62
|
rails db:migrate
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
This creates the `super_auth_*` tables (users, groups, roles, permissions, resources, edges, authorizations) alongside your application's tables.
|
|
65
|
+
This creates the `super_auth_*` tables (users, groups, roles, permissions, resources, edges, authorizations) alongside your application's tables. The engine does not run its migrations by itself, so repeat both commands after upgrading to a version that ships a new one (0.8.0 adds migration 11, `parent_id` on resources).
|
|
66
66
|
|
|
67
67
|
**Step 4.** Set the current user in your controller:
|
|
68
68
|
|
|
@@ -134,7 +134,7 @@ SuperAuth models authorization as a graph with 5 entity types:
|
|
|
134
134
|
| **Group** | Organizational units (teams, departments, etc) | Yes (nested) |
|
|
135
135
|
| **Role** | Job titles or permission sets | Yes (nested) |
|
|
136
136
|
| **Permission** | Actions (read, write, deploy, etc) | No |
|
|
137
|
-
| **Resource** | Things being protected (files, APIs, records) |
|
|
137
|
+
| **Resource** | Things being protected (files, APIs, records) | Yes (nested) |
|
|
138
138
|
|
|
139
139
|
**Edges** are connections drawn between any two entities. SuperAuth traverses the graph to find all valid paths from a User to a Resource. If a path exists, access is granted.
|
|
140
140
|
|
|
@@ -245,22 +245,77 @@ write_perm = SuperAuth::Permission.create(name: "write")
|
|
|
245
245
|
deploy_perm = SuperAuth::Permission.create(name: "deploy")
|
|
246
246
|
```
|
|
247
247
|
|
|
248
|
-
### Resources
|
|
248
|
+
### Resources (hierarchical)
|
|
249
249
|
|
|
250
|
-
Resources represent what you are protecting.
|
|
250
|
+
Resources represent what you are protecting. A node with neither `external_type` nor `external_id` is a container; a node with both points at one record of your application. Resources nest like groups and roles, and a grant on a node reaches the node and every node under it, so the usual shape is a container per folder, project, tenant or whatever your application nests records under, with the records registered beneath it.
|
|
251
251
|
|
|
252
252
|
```ruby
|
|
253
|
-
#
|
|
253
|
+
# A named resource with nothing behind it
|
|
254
254
|
staging = SuperAuth::Resource.create(name: "staging")
|
|
255
255
|
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
external_type: "Post"
|
|
256
|
+
# A container, and a record of your app registered under it
|
|
257
|
+
reports = SuperAuth::Resource.create(name: "reports")
|
|
258
|
+
q3 = SuperAuth::Resource.create(
|
|
259
|
+
name: "Q3 report",
|
|
260
|
+
external_type: "Post",
|
|
261
|
+
external_id: post.id,
|
|
262
|
+
parent: reports
|
|
261
263
|
)
|
|
264
|
+
|
|
265
|
+
# A grant on the container reaches q3, and every node registered under
|
|
266
|
+
# reports later, as of the next compile!
|
|
267
|
+
SuperAuth::Edge.create(permission: read_perm, resource: reports)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Navigate the tree the same way as groups:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
SuperAuth::Resource.roots # nodes with no parent
|
|
274
|
+
reports.children_dataset.all # => [q3]
|
|
275
|
+
q3.parent # => reports
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The compiled row for `q3` carries `q3`'s own `external_type` and `external_id` whether the edge was drawn to `q3` or to `reports`: runtime reads the record a grant reaches and nothing about how it got there. There are no resource path columns in the compiled table; "granted through which container" is a question for the graph (`parent`, `children_dataset`) and the editor.
|
|
279
|
+
|
|
280
|
+
#### Deprecated: type-level (wildcard) nodes
|
|
281
|
+
|
|
282
|
+
A node with an `external_type` and no `external_id` is a type-level, or wildcard, node: at runtime it means every record of that type, present and future. `ByCurrentUser` skips per-record filtering when one matches, and the row-level security policy's `resource_external_id IS NULL OR` clause does the same in the database.
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# Deprecated: every Post, present and future
|
|
286
|
+
posts = SuperAuth::Resource.create(name: "posts", external_type: "Post")
|
|
262
287
|
```
|
|
263
288
|
|
|
289
|
+
Wildcard nodes are deprecated as of 0.8.0 and still work: `compile!` warns once per compile naming the ones that exist (silence it with `SuperAuth.deprecator.silenced = true`, or in Rails through `config.active_support.deprecation`), and no removal version is promised. What `compile!` refuses, with a `SuperAuth::Error` naming the node ids, is a wildcard with a parent or children: nested in the tree it would reach every record of its type through its ancestors' grants. A wildcard stays at the root with no children, or gets an `external_id`.
|
|
290
|
+
|
|
291
|
+
Under Postgres row-level security a wildcard remains the only way to authorize INSERT this release. The policy is `FOR ALL` with `USING` reused as `WITH CHECK`, and a per-record row can only match an id that has already been registered and compiled. A container is not a replacement for it there: it loses INSERT, it needs a node saved and a full recompile for every new record, and saving that node needs a role that can write the gem's tables, which `enable` grants `SELECT` on only. The successor is a grant on a parent record (`SuperAuth::RLS.enable(:documents, resource_type: "Document", parent: { column: :folder_id, resource_type: "Folder" })`, with a `ByCurrentUser` mirror), planned for the next release.
|
|
292
|
+
|
|
293
|
+
##### Migrating a wildcard to a container
|
|
294
|
+
|
|
295
|
+
Where the type is not under row-level security, or INSERT is not needed, a wildcard becomes a container plus one node per record. Move the edges off the wildcard before destroying it, so the grants survive, then compile. `Post` is a `super_auth` model, so its default scope hides every row from a process with no current user — the loop reads through `unscoped`; where RLS is installed the database enforces the same rule, so the work runs as the system user:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
wildcard = SuperAuth::Resource.where(external_type: "Post", external_id: nil).first
|
|
299
|
+
|
|
300
|
+
migrate = proc do
|
|
301
|
+
container = SuperAuth::Resource.create(name: "posts") # untyped: a container
|
|
302
|
+
Post.unscoped.find_each do |post| # ByCurrentUser hides every row without a current user
|
|
303
|
+
SuperAuth::Resource.create(name: post.title, external_type: "Post", external_id: post.id, parent: container)
|
|
304
|
+
end
|
|
305
|
+
SuperAuth::Edge.where(resource_id: wildcard.id).update(resource_id: container.id) # update_all on the ActiveRecord twin
|
|
306
|
+
wildcard.destroy
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
if SuperAuth::RLS.installed?
|
|
310
|
+
SuperAuth.as(SuperAuth::User.system, &migrate)
|
|
311
|
+
else
|
|
312
|
+
SuperAuth.db.transaction(&migrate)
|
|
313
|
+
end
|
|
314
|
+
SuperAuth::Authorization.compile! # SuperAuth::ActiveRecord::Authorization.compile! in Rails
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Under row-level security this loses INSERT on `posts` until the parent-record grant exists, and a `Post` created afterwards needs its own node and a recompile before anyone sees it.
|
|
318
|
+
|
|
264
319
|
## Drawing Edges
|
|
265
320
|
|
|
266
321
|
Edges are the core of SuperAuth. Each edge connects exactly two entities.
|
|
@@ -311,6 +366,8 @@ SuperAuth automatically evaluates 5 pathing strategies and unions the results. Y
|
|
|
311
366
|
|
|
312
367
|
When groups or roles are nested, SuperAuth considers the full tree. If you assign a user to a parent group, they can access resources through roles attached to that group *and all its descendants*.
|
|
313
368
|
|
|
369
|
+
Resources nest too, at the other end of the path: a grant on a container reaches every node registered under it (see [Resources](#resources-hierarchical)).
|
|
370
|
+
|
|
314
371
|
```ruby
|
|
315
372
|
# Bethany is in Company (the root group)
|
|
316
373
|
SuperAuth::Edge.create(user: bethany, group: company)
|
|
@@ -349,6 +406,8 @@ auth[:resource_id] # Integer
|
|
|
349
406
|
auth[:resource_name] # "staging"
|
|
350
407
|
```
|
|
351
408
|
|
|
409
|
+
`resource_id` and `resource_name` are the node the grant reaches: a row compiled through a container names the descendant, not the container, and there is no resource path column (see [Resources](#resources-hierarchical)).
|
|
410
|
+
|
|
352
411
|
### Filter by user
|
|
353
412
|
|
|
354
413
|
```ruby
|
|
@@ -461,6 +520,8 @@ Post::PostPublishPermission.find(id) # needs a "Post::PostPublishPermission" gra
|
|
|
461
520
|
|
|
462
521
|
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!`.
|
|
463
522
|
|
|
523
|
+
The resource tree is containment, not inheritance. A row compiled through a container copies the descendant node's own `external_type`, which is why the rule above survives nesting — but a `"Post::PostPublishPermission"` node registered *under* the `"Post"` node is a descendant of it and receives every grant on `"Post"`. Register capability nodes as siblings of their base-class nodes, or in a container beside them, never as their children.
|
|
524
|
+
|
|
464
525
|
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`).
|
|
465
526
|
|
|
466
527
|
### Linking to your app's models
|
|
@@ -475,14 +536,15 @@ sa_user = SuperAuth::User.create(
|
|
|
475
536
|
external_type: "User"
|
|
476
537
|
)
|
|
477
538
|
|
|
478
|
-
# Link a SuperAuth resource to
|
|
539
|
+
# Link a SuperAuth resource to one Post
|
|
479
540
|
sa_resource = SuperAuth::Resource.create(
|
|
480
|
-
name:
|
|
481
|
-
external_type: "Post"
|
|
541
|
+
name: post.title,
|
|
542
|
+
external_type: "Post",
|
|
543
|
+
external_id: post.id
|
|
482
544
|
)
|
|
483
545
|
```
|
|
484
546
|
|
|
485
|
-
When `super_auth` is included in a model, the default scope matches the current user's `id` and class name against `external_id` / `external_type` in the authorizations table. This means your application user objects work directly -- no need to convert to SuperAuth users in the controller.
|
|
547
|
+
When `super_auth` is included in a model, the default scope matches the current user's `id` and class name against `external_id` / `external_type` in the authorizations table. This means your application user objects work directly -- no need to convert to SuperAuth users in the controller. On the resource side it matches the record's class name and `id` against `resource_external_type` / `resource_external_id`; a node with the type and no id matches every record of the type, the deprecated wildcard shape described under [Resources](#resources-hierarchical).
|
|
486
548
|
|
|
487
549
|
### ActiveRecord models
|
|
488
550
|
|
|
@@ -538,9 +600,11 @@ deployers.map { |a| a[:user_name] }.uniq
|
|
|
538
600
|
## Visualization
|
|
539
601
|
|
|
540
602
|
The graph editor shows the whole graph as five boxes (groups, roles, users,
|
|
541
|
-
permissions, resources)
|
|
542
|
-
it
|
|
543
|
-
|
|
603
|
+
permissions, resources), drawing groups, roles and resources as trees; click any record
|
|
604
|
+
to trace what it can reach and what reaches it, connect records to draw edges, create
|
|
605
|
+
records (including a resource container under a chosen parent), delete records and
|
|
606
|
+
edges, and recompile. The editor makes containers; your application registers records
|
|
607
|
+
under them. See the README's "Graph editor" section for the full description.
|
|
544
608
|
|
|
545
609
|
- Rails: mount the engine inside your own authentication (Step 6 above) and open
|
|
546
610
|
`http://localhost:3000/super_auth`.
|
|
@@ -577,8 +641,8 @@ write = SuperAuth::Permission.create(name: "write")
|
|
|
577
641
|
deploy = SuperAuth::Permission.create(name: "deploy")
|
|
578
642
|
|
|
579
643
|
# Resources
|
|
580
|
-
api = SuperAuth::Resource.create(name: "api"
|
|
581
|
-
dashboard = SuperAuth::Resource.create(name: "dashboard"
|
|
644
|
+
api = SuperAuth::Resource.create(name: "api")
|
|
645
|
+
dashboard = SuperAuth::Resource.create(name: "dashboard")
|
|
582
646
|
prod_db = SuperAuth::Resource.create(name: "production_db")
|
|
583
647
|
|
|
584
648
|
# Users
|
|
@@ -631,6 +695,7 @@ auths = SuperAuth::Edge.authorizations.all
|
|
|
631
695
|
| `SuperAuth.current_user` | Get the current user |
|
|
632
696
|
| `SuperAuth.install_migrations` | Create all `super_auth_*` tables |
|
|
633
697
|
| `SuperAuth.uninstall_migrations`| Drop all `super_auth_*` tables |
|
|
698
|
+
| `SuperAuth.deprecator` | Where deprecation warnings go; `silenced = true` quiets them |
|
|
634
699
|
|
|
635
700
|
### Environment Variables
|
|
636
701
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
# Resources nest like groups and roles (2_groups.rb): same integer type as
|
|
3
|
+
# the pk, deferrable only where supported. The index is skipped on MySQL
|
|
4
|
+
# for the reason in 8_add_indexes_to_edges.rb — InnoDB indexes the foreign
|
|
5
|
+
# key column itself and will not drop that index while the constraint
|
|
6
|
+
# stands. Both branches compute database_type before alter_table: inside
|
|
7
|
+
# the block self is the generator.
|
|
8
|
+
up do
|
|
9
|
+
is_postgres = database_type == :postgres
|
|
10
|
+
is_mysql = [:mysql, :mysql2].include?(database_type)
|
|
11
|
+
|
|
12
|
+
alter_table(:super_auth_resources) do
|
|
13
|
+
if is_postgres
|
|
14
|
+
add_foreign_key :parent_id, :super_auth_resources, deferrable: true, type: :integer
|
|
15
|
+
else
|
|
16
|
+
add_foreign_key :parent_id, :super_auth_resources, type: :integer
|
|
17
|
+
end
|
|
18
|
+
add_index :parent_id unless is_mysql
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# drop_foreign_key drops the constraint and then the column; MySQL refuses
|
|
23
|
+
# to drop a column a constraint still depends on.
|
|
24
|
+
down do
|
|
25
|
+
is_mysql = [:mysql, :mysql2].include?(database_type)
|
|
26
|
+
|
|
27
|
+
alter_table(:super_auth_resources) do
|
|
28
|
+
drop_index :parent_id unless is_mysql
|
|
29
|
+
drop_foreign_key :parent_id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddParentIdToSuperAuthResources < ActiveRecord::Migration[7.0]
|
|
2
|
+
# Mirrors db/migrate/11_add_parent_id_to_resources.rb. No index on MySQL:
|
|
3
|
+
# InnoDB indexes the foreign key column itself.
|
|
4
|
+
def change
|
|
5
|
+
add_column :super_auth_resources, :parent_id, :bigint
|
|
6
|
+
add_foreign_key :super_auth_resources, :super_auth_resources, column: :parent_id
|
|
7
|
+
add_index :super_auth_resources, :parent_id unless connection.adapter_name.match?(/mysql|trilogy/i)
|
|
8
|
+
end
|
|
9
|
+
end
|
data/db/seeds/sample_data.rb
CHANGED
|
@@ -4,11 +4,13 @@ SuperAuth initializer created at config/initializers/super_auth.rb
|
|
|
4
4
|
|
|
5
5
|
Next steps:
|
|
6
6
|
|
|
7
|
-
1.
|
|
7
|
+
1. Copy the engine's migrations into your app and run them:
|
|
8
8
|
|
|
9
|
+
rails railties:install:migrations
|
|
9
10
|
rails db:migrate
|
|
10
11
|
|
|
11
|
-
(the engine
|
|
12
|
+
(the engine does not run its migrations by itself; repeat both after an
|
|
13
|
+
upgrade that ships a new one)
|
|
12
14
|
|
|
13
15
|
2. Mount the engine in config/routes.rb, inside your own authentication.
|
|
14
16
|
It serves the graph editor, which has no authentication of its own:
|
|
@@ -9,11 +9,18 @@ class SuperAuth::ActiveRecord::Authorization < ActiveRecord::Base
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
# Clears and repopulates the authorizations table from the current graph.
|
|
12
|
+
# The wildcard guard runs before the delete, so a refused compile leaves
|
|
13
|
+
# the previous rows in place; the deprecation notice follows the commit.
|
|
12
14
|
def compile!
|
|
13
15
|
transaction do
|
|
16
|
+
# Sequel runs on this transaction's connection (sequel-activerecord_connection),
|
|
17
|
+
# so the JIT switch lands in it; see SuperAuth::Authorization.compile!.
|
|
18
|
+
SuperAuth.db.run "SET LOCAL jit = off" if SuperAuth.db.database_type == :postgres
|
|
19
|
+
SuperAuth::Resource.assert_compilable!
|
|
14
20
|
delete_all
|
|
15
21
|
from_graph.each { |auth| create!(auth.attributes.except("id")) }
|
|
16
22
|
end
|
|
23
|
+
SuperAuth::Resource.warn_deprecated_wildcards
|
|
17
24
|
count
|
|
18
25
|
end
|
|
19
26
|
end
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
|
|
2
2
|
self.table_name = 'super_auth_resources'
|
|
3
3
|
belongs_to :external, polymorphic: true, optional: true
|
|
4
|
+
# optional: is load-bearing: Rails hosts set belongs_to_required_by_default,
|
|
5
|
+
# the gem's own suite does not, so a missing one passes CI and fails the
|
|
6
|
+
# host on every root node.
|
|
7
|
+
belongs_to :parent, class_name: 'SuperAuth::ActiveRecord::Resource', optional: true
|
|
4
8
|
|
|
5
9
|
# `super_auth_label` is a stored snapshot of the application record's human
|
|
6
10
|
# name, so the editor can render "Gulf War presumptive" instead of
|
|
@@ -33,10 +37,10 @@ class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
|
|
|
33
37
|
# path, and never fails the save. Three things derive nil and none of them
|
|
34
38
|
# means "this record has no name": RLS makes the application record
|
|
35
39
|
# unreadable without an asserted identity, external_type is a plain string
|
|
36
|
-
# that can name a class this process has not loaded, and
|
|
37
|
-
#
|
|
38
|
-
# of them would turn "this label is stale" into data,
|
|
39
|
-
# this column exists to avoid.
|
|
40
|
+
# that can name a class this process has not loaded, and id-less rows — a
|
|
41
|
+
# container, or a deprecated wildcard — have no record to name at all.
|
|
42
|
+
# Writing nil for any of them would turn "this label is stale" into data,
|
|
43
|
+
# which is the failure this column exists to avoid.
|
|
40
44
|
def derived_label
|
|
41
45
|
SuperAuth.label_for(external)
|
|
42
46
|
rescue NameError
|
|
@@ -4,11 +4,23 @@ class SuperAuth::Authorization < Sequel::Model(:super_auth_authorizations)
|
|
|
4
4
|
# SuperAuth::ActiveRecord::Authorization.compile!; a single INSERT ... SELECT
|
|
5
5
|
# is a separate change. Runtime enforcement (ByCurrentUser, the RLS policies)
|
|
6
6
|
# reads only this table, so every edit to the graph is inert until this runs.
|
|
7
|
+
# The wildcard guard runs first, before the delete, so a refused compile
|
|
8
|
+
# leaves the previous rows in place rather than an empty table; the
|
|
9
|
+
# deprecation notice comes after the commit, for a compile that happened.
|
|
10
|
+
#
|
|
11
|
+
# Postgres JIT-compiles the union's expressions on every run: 539 LLVM
|
|
12
|
+
# functions, 1.6-2.2s of optimisation and emission for a query that then
|
|
13
|
+
# executes in milliseconds. SET LOCAL scopes the switch to this transaction,
|
|
14
|
+
# so nothing leaks to the pooled connection.
|
|
7
15
|
def self.compile!
|
|
8
|
-
db.transaction do
|
|
16
|
+
count = db.transaction do
|
|
17
|
+
db.run "SET LOCAL jit = off" if db.database_type == :postgres
|
|
18
|
+
SuperAuth::Resource.assert_compilable!
|
|
9
19
|
dataset.delete
|
|
10
20
|
SuperAuth::Edge.authorizations.each { |row| dataset.insert(row) }
|
|
11
21
|
dataset.count
|
|
12
22
|
end
|
|
23
|
+
SuperAuth::Resource.warn_deprecated_wildcards
|
|
24
|
+
count
|
|
13
25
|
end
|
|
14
26
|
end
|
data/lib/super_auth/edge.rb
CHANGED
|
@@ -39,6 +39,45 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
39
39
|
.union(users_resources)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# Every resource node a grant reaches: each granted node paired with
|
|
43
|
+
# itself and each of its descendants, as (ancestor_id, descendant_id).
|
|
44
|
+
# Anchored on the ids that appear in edges rather than on the whole table.
|
|
45
|
+
# Groups and roles are few, but resources are one row per protected
|
|
46
|
+
# record, and the unanchored CTE materialises every pair of the table once
|
|
47
|
+
# per strategy — 27s per strategy on MySQL at 300k resources, 0.025s
|
|
48
|
+
# anchored — so the walk is sized by the grants, not by the table. On a
|
|
49
|
+
# flat graph it is the identity relation and the compiled rows are exactly
|
|
50
|
+
# what the previous pk join produced.
|
|
51
|
+
def resource_subtrees
|
|
52
|
+
granted = db[:super_auth_edges].exclude(resource_id: nil).select(:resource_id)
|
|
53
|
+
SuperAuth::Resource.descendant_pairs(of: granted)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The last hop of every strategy: from the resource id on an edge to the
|
|
57
|
+
# node it names and each node under it. The compiled row copies the
|
|
58
|
+
# descendant's own columns, so containment is not inheritance — a node
|
|
59
|
+
# keeps its own external_type — and the type-level tricks in ByCurrentUser
|
|
60
|
+
# still hold. No resource path columns, unlike groups and roles: nothing at
|
|
61
|
+
# runtime reads one, and super_auth_authorizations gains no columns.
|
|
62
|
+
def join_resource_subtree(ds, resource_id_column)
|
|
63
|
+
ds.
|
|
64
|
+
join(resource_subtrees.as(:resource_descendants), ancestor_id: resource_id_column).
|
|
65
|
+
join(Sequel[:super_auth_resources], id: Sequel[:resource_descendants][:descendant_id]).
|
|
66
|
+
# A (type, NULL) row — a wildcard, every record of its type — is only
|
|
67
|
+
# ever the node the grant named, never one reached through the tree.
|
|
68
|
+
# Resource.assert_compilable! refuses that shape loudly, but it is a
|
|
69
|
+
# separate statement from this one: under READ COMMITTED a write that
|
|
70
|
+
# nests a wildcard can land between the two, and the compiled table
|
|
71
|
+
# must not widen a container grant to a whole type because of it.
|
|
72
|
+
where(
|
|
73
|
+
Sequel.|(
|
|
74
|
+
{ Sequel[:resource_descendants][:ancestor_id] => Sequel[:resource_descendants][:descendant_id] },
|
|
75
|
+
{ Sequel[:super_auth_resources][:external_type] => nil },
|
|
76
|
+
Sequel.~(Sequel[:super_auth_resources][:external_id] => nil)
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
42
81
|
def users_groups_roles_permissions_resources
|
|
43
82
|
cast_type = string_cast_type
|
|
44
83
|
# Join users to their group via edges. group_ancestors pairs that group with itself and
|
|
@@ -46,7 +85,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
46
85
|
# expands the granted role to its whole subtree. Each step is correlated to the previous
|
|
47
86
|
# one, so a role held by one group never reaches members of an unrelated group. The tree
|
|
48
87
|
# CTEs (user_groups, granted_roles) are joined by id only to supply the path columns.
|
|
49
|
-
SuperAuth::User.db[:super_auth_users].
|
|
88
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
50
89
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
51
90
|
join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
|
|
52
91
|
join(Sequel[:super_auth_edges].as(:group_role_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
|
|
@@ -56,8 +95,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
56
95
|
join(SuperAuth::Role.from(SuperAuth::Role.trees).as(:granted_roles), Sequel[:granted_roles][:id] => Sequel[:role_descendants][:descendant_id]).
|
|
57
96
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
|
|
58
97
|
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
|
-
|
|
98
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
99
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).
|
|
61
100
|
select(
|
|
62
101
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
63
102
|
Sequel[:super_auth_users][:name].as(:user_name),
|
|
@@ -100,14 +139,14 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
100
139
|
# Join users to their group via edges. group_ancestors pairs that group with itself and
|
|
101
140
|
# every ancestor, so a group -> permission edge on any of them applies. user_groups (the
|
|
102
141
|
# tree) is joined by id only to supply the path columns.
|
|
103
|
-
SuperAuth::User.db[:super_auth_users].
|
|
142
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
104
143
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
105
144
|
join(SuperAuth::Group.ancestor_pairs.as(:group_ancestors), descendant_id: Sequel[:user_edges][:group_id]).
|
|
106
145
|
join(Sequel[:super_auth_edges].as(:group_edges), group_id: Sequel[:group_ancestors][:ancestor_id]).
|
|
107
146
|
join(SuperAuth::Group.from(SuperAuth::Group.trees).as(:user_groups), Sequel[:user_groups][:id] => Sequel[:user_edges][:group_id]).
|
|
108
147
|
join(Sequel[:super_auth_permissions], id: Sequel[:group_edges][:permission_id]).
|
|
109
|
-
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
110
|
-
|
|
148
|
+
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
149
|
+
join_resource_subtree(ds, Sequel[:permission_edges][:resource_id]).
|
|
111
150
|
select(
|
|
112
151
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
113
152
|
Sequel[:super_auth_users][:name].as(:user_name),
|
|
@@ -150,7 +189,7 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
150
189
|
|
|
151
190
|
# Join users to the roles they hold directly. role_descendants expands each held role to
|
|
152
191
|
# its whole subtree; granted_roles (the tree) is joined by id only to supply the path columns.
|
|
153
|
-
SuperAuth::User.db[:super_auth_users].
|
|
192
|
+
ds = SuperAuth::User.db[:super_auth_users].
|
|
154
193
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
155
194
|
where(Sequel.~(Sequel[:user_edges][:role_id] => nil)).
|
|
156
195
|
join(SuperAuth::Role.descendant_pairs.as(:role_descendants), ancestor_id: Sequel[:user_edges][:role_id]).
|
|
@@ -192,14 +231,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
192
231
|
# Join permission and resource edges on the expanded role
|
|
193
232
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:role_id] => Sequel[:granted_roles][:id]).
|
|
194
233
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
195
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
196
|
-
|
|
197
|
-
distinct
|
|
234
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
235
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
|
|
198
236
|
end
|
|
199
237
|
|
|
200
238
|
def users_permissions_resources
|
|
201
239
|
cast_type = string_cast_type
|
|
202
|
-
SuperAuth::User.
|
|
240
|
+
ds = SuperAuth::User.
|
|
203
241
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
204
242
|
select(
|
|
205
243
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
@@ -237,14 +275,13 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
237
275
|
).
|
|
238
276
|
join(Sequel[:super_auth_edges].as(:permission_edges), Sequel[:permission_edges][:user_id] => Sequel[:super_auth_users][:id]).
|
|
239
277
|
join(Sequel[:super_auth_permissions], id: Sequel[:permission_edges][:permission_id]).
|
|
240
|
-
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
241
|
-
|
|
242
|
-
distinct
|
|
278
|
+
join(Sequel[:super_auth_edges].as(:resource_edges), Sequel[:resource_edges][:permission_id] => Sequel[:super_auth_permissions][:id])
|
|
279
|
+
join_resource_subtree(ds, Sequel[:resource_edges][:resource_id]).distinct
|
|
243
280
|
end
|
|
244
281
|
|
|
245
282
|
def users_resources
|
|
246
283
|
cast_type = string_cast_type
|
|
247
|
-
SuperAuth::User.
|
|
284
|
+
ds = SuperAuth::User.
|
|
248
285
|
join(Sequel[:super_auth_edges].as(:user_edges), user_id: :id).
|
|
249
286
|
select(
|
|
250
287
|
Sequel[:super_auth_users][:id].as(:user_id),
|
|
@@ -279,9 +316,8 @@ class SuperAuth::Edge < Sequel::Model(:super_auth_edges)
|
|
|
279
316
|
Sequel[:super_auth_resources][:name].as(:resource_name),
|
|
280
317
|
Sequel[:super_auth_resources][:external_id].as(:resource_external_id),
|
|
281
318
|
Sequel[:super_auth_resources][:external_type].as(:resource_external_type)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
distinct
|
|
319
|
+
)
|
|
320
|
+
join_resource_subtree(ds, Sequel[:user_edges][:resource_id]).distinct
|
|
285
321
|
end
|
|
286
322
|
end
|
|
287
323
|
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
.region{display:grid;gap:10px;min-height:0}
|
|
54
54
|
.region.top{flex:1.15;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;
|
|
55
55
|
grid-template-areas:"group role" "user perm"}
|
|
56
|
-
.region.bottom{flex:.85;grid-template-columns:1fr
|
|
57
|
-
grid-template-areas:"resource
|
|
56
|
+
.region.bottom{flex:.85;grid-template-columns:1fr;grid-template-rows:1fr;
|
|
57
|
+
grid-template-areas:"resource"}
|
|
58
58
|
|
|
59
59
|
/* ---- box ---- */
|
|
60
60
|
.box{background:var(--panel);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
|
@@ -63,7 +63,6 @@
|
|
|
63
63
|
.box[data-area=user]{grid-area:user;--accent:var(--c-user)}
|
|
64
64
|
.box[data-area=permission]{grid-area:perm;--accent:var(--c-perm)}
|
|
65
65
|
.box[data-area=resource]{grid-area:resource;--accent:var(--c-resource)}
|
|
66
|
-
.box[data-area=user2]{grid-area:user2;--accent:var(--c-user)}
|
|
67
66
|
.box-head{display:flex;align-items:center;gap:9px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
|
|
68
67
|
.box-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none;box-shadow:0 0 10px -2px var(--accent)}
|
|
69
68
|
.box-head .title{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.02em}
|
|
@@ -122,7 +121,6 @@
|
|
|
122
121
|
</section>
|
|
123
122
|
<section class="region bottom">
|
|
124
123
|
<div class="box" data-area="resource" data-type="resource"></div>
|
|
125
|
-
<div class="box" data-area="user2" data-type="user"></div>
|
|
126
124
|
</section>
|
|
127
125
|
</main>
|
|
128
126
|
|
|
@@ -130,7 +128,7 @@
|
|
|
130
128
|
// The app may be mounted under a prefix; every API call is relative to this page.
|
|
131
129
|
const API = location.pathname.replace(/\/$/, "");
|
|
132
130
|
const TITLES = {group:"Groups", role:"Roles", user:"Users", permission:"Permissions", resource:"Resources"};
|
|
133
|
-
const NESTED = new Set(["group","role"]);
|
|
131
|
+
const NESTED = new Set(["group","role","resource"]);
|
|
134
132
|
|
|
135
133
|
let GRAPH = null; // {groups,roles,users,permissions,resources,edges,authorizations_count}
|
|
136
134
|
let selection = null; // {type,id}
|
|
@@ -157,8 +155,8 @@ async function load(){
|
|
|
157
155
|
// Authorization flows user → group → role → permission → resource. From the
|
|
158
156
|
// selected node we collect everything DOWNSTREAM (what it can reach) and
|
|
159
157
|
// everything UPSTREAM (what can reach it). So a user shows what they can access;
|
|
160
|
-
// a resource shows who can access it.
|
|
161
|
-
//
|
|
158
|
+
// a resource shows who can access it. The group, role and resource trees are
|
|
159
|
+
// each folded in as one more directed hop (see buildDirected).
|
|
162
160
|
const RANK = {user:0, group:1, role:2, permission:3, resource:4};
|
|
163
161
|
|
|
164
162
|
function buildDirected(){
|
|
@@ -184,9 +182,13 @@ function buildDirected(){
|
|
|
184
182
|
// inherits ANCESTOR grants → a child group is upstream of its parent.
|
|
185
183
|
// • Role grants flow to DESCENDANT roles, so holding a parent role includes
|
|
186
184
|
// its children → a parent role is upstream of its child.
|
|
185
|
+
// • Resource grants reach DESCENDANT resources: a grant on a container
|
|
186
|
+
// covers everything under it → a parent resource is upstream of its
|
|
187
|
+
// children, the same direction as roles and the opposite of groups.
|
|
187
188
|
// (Selecting a child group therefore reaches its ancestors, never its siblings.)
|
|
188
189
|
for(const g of GRAPH.groups) if(g.parent_id) dir(key("group",g.id), key("group",g.parent_id));
|
|
189
190
|
for(const r of GRAPH.roles) if(r.parent_id) dir(key("role",r.parent_id), key("role",r.id));
|
|
191
|
+
for(const r of GRAPH.resources) if(r.parent_id) dir(key("resource",r.parent_id), key("resource",r.id));
|
|
190
192
|
return {fwd,bwd};
|
|
191
193
|
}
|
|
192
194
|
|
|
@@ -248,11 +250,16 @@ function render(){
|
|
|
248
250
|
const name = n.name ?? "";
|
|
249
251
|
// The external record occupies one slot: its label when the graph
|
|
250
252
|
// stored one, otherwise the Type#id that used to be the only
|
|
251
|
-
// rendering. Either way the other half is the tooltip.
|
|
253
|
+
// rendering. Either way the other half is the tooltip. A type with no
|
|
254
|
+
// id is the deprecated type-level (wildcard) node — every record of
|
|
255
|
+
// the type — and its tooltip says so, since Type#* alone reads as a
|
|
256
|
+
// typo.
|
|
257
|
+
const wildcard = type === "resource" && !!n.external_type && n.external_id == null;
|
|
252
258
|
const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
|
|
259
|
+
const kind = wildcard ? "type-level (deprecated)" : "external record";
|
|
253
260
|
const ext = !ref ? "" : (n.super_auth_label
|
|
254
|
-
? `<span class="ext" title="${ref}">${escapeHtml(n.super_auth_label)}</span>`
|
|
255
|
-
: `<span class="ext" title="
|
|
261
|
+
? `<span class="ext" title="${wildcard ? `${ref} · ${kind}` : ref}">${escapeHtml(n.super_auth_label)}</span>`
|
|
262
|
+
: `<span class="ext" title="${kind}">${ref}</span>`);
|
|
256
263
|
return `<div class="item ${isSel?'selected':''} ${isCF?'connect-first':''}" data-id="${n.id}" ${pad}>
|
|
257
264
|
<span class="tick"></span>
|
|
258
265
|
${arrow}<span class="name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>${ext}
|
|
@@ -15,6 +15,11 @@ module SuperAuth
|
|
|
15
15
|
# does not.
|
|
16
16
|
# - "Support Lead" is the PARENT role of "Support Agent": a lead inherits
|
|
17
17
|
# the agent's abilities plus refunds; an agent does not get refunds.
|
|
18
|
+
# - "clusters" is a CONTAINER resource holding production_cluster and
|
|
19
|
+
# staging_cluster. deploy is granted on the container and reaches both
|
|
20
|
+
# clusters through the tree; restart_server and Morgan's direct grant
|
|
21
|
+
# name production_cluster alone, so the tree shows a container grant
|
|
22
|
+
# and a leaf grant side by side.
|
|
18
23
|
#
|
|
19
24
|
# Special people:
|
|
20
25
|
# - Riley (Auditor): read-only into BOTH Finance and Support
|
|
@@ -68,8 +73,9 @@ module SuperAuth
|
|
|
68
73
|
|
|
69
74
|
# ===== RESOURCES (disjoint per department) =====
|
|
70
75
|
source_repo = res_m.create(name: "source_repo")
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
clusters = res_m.create(name: "clusters") # container
|
|
77
|
+
production_cluster = res_m.create(name: "production_cluster", parent_id: clusters.id)
|
|
78
|
+
res_m.create(name: "staging_cluster", parent_id: clusters.id) # reached only through clusters
|
|
73
79
|
app_database = res_m.create(name: "app_database") # Backend
|
|
74
80
|
marketing_site = res_m.create(name: "marketing_site") # Frontend
|
|
75
81
|
general_ledger = res_m.create(name: "general_ledger")
|
|
@@ -99,8 +105,8 @@ module SuperAuth
|
|
|
99
105
|
edg.create(role_id: developer.id, permission_id: deploy.id)
|
|
100
106
|
edg.create(permission_id: merge_code.id, resource_id: source_repo.id)
|
|
101
107
|
edg.create(permission_id: read_repo.id, resource_id: source_repo.id)
|
|
102
|
-
|
|
103
|
-
edg.create(permission_id: deploy.id, resource_id:
|
|
108
|
+
# One grant on the container reaches both clusters.
|
|
109
|
+
edg.create(permission_id: deploy.id, resource_id: clusters.id)
|
|
104
110
|
# Child-group-specific grants (Alice gets one, Bob the other)
|
|
105
111
|
edg.create(group_id: backend.id, permission_id: run_migrations.id)
|
|
106
112
|
edg.create(permission_id: run_migrations.id, resource_id: app_database.id)
|
|
@@ -153,7 +159,7 @@ module SuperAuth
|
|
|
153
159
|
def clear!
|
|
154
160
|
SuperAuth::Edge.dataset.delete
|
|
155
161
|
SuperAuth::Authorization.dataset.delete
|
|
156
|
-
[SuperAuth::Group, SuperAuth::Role].each { |m| m.dataset.update(parent_id: nil) }
|
|
162
|
+
[SuperAuth::Group, SuperAuth::Role, SuperAuth::Resource].each { |m| m.dataset.update(parent_id: nil) }
|
|
157
163
|
[SuperAuth::Group, SuperAuth::Role, SuperAuth::User, SuperAuth::Permission, SuperAuth::Resource].each do |m|
|
|
158
164
|
m.dataset.delete
|
|
159
165
|
end
|
data/lib/super_auth/editor.rb
CHANGED
|
@@ -3,7 +3,8 @@ require "super_auth"
|
|
|
3
3
|
|
|
4
4
|
module SuperAuth
|
|
5
5
|
# A small Rack application that edits the authorization graph: five boxes of
|
|
6
|
-
# records
|
|
6
|
+
# records (groups, roles and resources drawn as trees), client-side
|
|
7
|
+
# traversal, node and edge CRUD, and a Recompile button.
|
|
7
8
|
# Rails-free; it needs only SuperAuth.db to be connected and the tables to
|
|
8
9
|
# exist. Mount it as `run SuperAuth::Editor` (Rack) or
|
|
9
10
|
# `mount SuperAuth::Editor => "/super_auth/editor"` (Rails), or run
|
|
@@ -19,7 +20,9 @@ module SuperAuth
|
|
|
19
20
|
#
|
|
20
21
|
# Edits change the graph, not runtime access: ByCurrentUser and the RLS
|
|
21
22
|
# policies read the compiled super_auth_authorizations table, so the UI
|
|
22
|
-
# shows its row count and offers POST /api/compile.
|
|
23
|
+
# shows its row count and offers POST /api/compile. A compile the models
|
|
24
|
+
# refuse (SuperAuth::Error, the wildcard guard) comes back as a 422 with
|
|
25
|
+
# the model's own message, like any other rejected write.
|
|
23
26
|
class Editor
|
|
24
27
|
TYPES = {
|
|
25
28
|
"user" => :User, "group" => :Group, "role" => :Role,
|
|
@@ -29,7 +32,7 @@ module SuperAuth
|
|
|
29
32
|
"user" => :user_id, "group" => :group_id, "role" => :role_id,
|
|
30
33
|
"permission" => :permission_id, "resource" => :resource_id,
|
|
31
34
|
}.freeze
|
|
32
|
-
NESTED = %w[group role].freeze
|
|
35
|
+
NESTED = %w[group role resource].freeze
|
|
33
36
|
# The pairs the path strategies read (see Edge.authorizations), unordered.
|
|
34
37
|
# The models also accept group->resource and role->resource rows, but no
|
|
35
38
|
# strategy reads them, so they would grant nothing.
|
|
@@ -69,6 +72,8 @@ module SuperAuth
|
|
|
69
72
|
end
|
|
70
73
|
|
|
71
74
|
route(method, path, env)
|
|
75
|
+
rescue SuperAuth::Error => e
|
|
76
|
+
json(422, error: e.message)
|
|
72
77
|
rescue Sequel::Error
|
|
73
78
|
json(422, error: "the database rejected the change")
|
|
74
79
|
end
|
|
@@ -103,7 +108,7 @@ module SuperAuth
|
|
|
103
108
|
roles: nodes(:Role, :parent_id),
|
|
104
109
|
users: nodes(:User, :external_id, :external_type),
|
|
105
110
|
permissions: nodes(:Permission),
|
|
106
|
-
resources: nodes(:Resource, :external_id, :external_type, :super_auth_label),
|
|
111
|
+
resources: nodes(:Resource, :parent_id, :external_id, :external_type, :super_auth_label),
|
|
107
112
|
edges: SuperAuth::Edge.order(:id).map { |e| edge_json(e) },
|
|
108
113
|
authorizations_count: SuperAuth::Authorization.count,
|
|
109
114
|
}
|
|
@@ -122,9 +127,12 @@ module SuperAuth
|
|
|
122
127
|
# indentation, so a child has to arrive immediately after its parent or
|
|
123
128
|
# it reads as nested under whatever happens to sort above it — which is
|
|
124
129
|
# the one question an auditor opens this editor to answer. Sorting by the
|
|
125
|
-
# ancestors' [name, id]
|
|
126
|
-
# own parent and leaves siblings alphabetical
|
|
127
|
-
#
|
|
130
|
+
# ancestors' [name, label, id] triples, outermost first, puts every child
|
|
131
|
+
# under its own parent and leaves siblings alphabetical; the label only
|
|
132
|
+
# separates same-named siblings, which synced resources are (one "Claim"
|
|
133
|
+
# per record), and is absent from groups and roles. All three node sets
|
|
134
|
+
# are small enough to order in Ruby, and the client's depthOf is
|
|
135
|
+
# unaffected.
|
|
128
136
|
#
|
|
129
137
|
# The key is total, so the order stays defined for broken trees: a row
|
|
130
138
|
# whose parent_id names a missing row sorts as a root, and a parent cycle
|
|
@@ -137,7 +145,7 @@ module SuperAuth
|
|
|
137
145
|
node = row
|
|
138
146
|
while node && !seen[node[:id]]
|
|
139
147
|
seen[node[:id]] = true
|
|
140
|
-
path.unshift([node[:name].to_s, node[:id]])
|
|
148
|
+
path.unshift([node[:name].to_s, node[:super_auth_label].to_s, node[:id]])
|
|
141
149
|
node = by_id[node[:parent_id]]
|
|
142
150
|
end
|
|
143
151
|
path
|
|
@@ -158,7 +166,14 @@ module SuperAuth
|
|
|
158
166
|
unless parent.nil?
|
|
159
167
|
return json(422, error: "#{type} records cannot have a parent") unless NESTED.include?(type)
|
|
160
168
|
return json(422, error: "parent_id must be an integer") unless integer_id?(parent)
|
|
161
|
-
|
|
169
|
+
parent_node = model[parent.to_i]
|
|
170
|
+
return json(422, error: "parent not found") unless parent_node
|
|
171
|
+
# "Wildcard nodes are flat": compile! refuses a tree with a type-level
|
|
172
|
+
# node in it, so refuse the shape at the door with the reason instead.
|
|
173
|
+
if type == "resource" && parent_node.external_type && parent_node.external_id.nil?
|
|
174
|
+
return json(422, error: "type-level (wildcard) resources are deprecated and cannot contain other resources; " \
|
|
175
|
+
"make a container (a resource with no external type) instead")
|
|
176
|
+
end
|
|
162
177
|
attrs[:parent_id] = parent.to_i
|
|
163
178
|
end
|
|
164
179
|
|
data/lib/super_auth/nestable.rb
CHANGED
|
@@ -53,24 +53,37 @@ module SuperAuth::Nestable
|
|
|
53
53
|
# pairs on equality; matching ids inside the comma-separated path strings
|
|
54
54
|
# with LIKE forced a nested loop no planner could index, and compile time
|
|
55
55
|
# grew roughly cubically with the graph.
|
|
56
|
+
#
|
|
57
|
+
# Both pair CTEs recurse with UNION rather than UNION ALL. The pair
|
|
58
|
+
# relation is finite (at most n² rows), so UNION stops as soon as a step
|
|
59
|
+
# produces nothing new, which on a parent_id cycle is the first time round;
|
|
60
|
+
# UNION ALL re-derives the same pairs forever and compile! never returns.
|
|
61
|
+
# On a valid tree no step repeats a pair, so the output is the same.
|
|
56
62
|
def ancestor_pairs
|
|
57
63
|
table = pluralize
|
|
58
64
|
name = :"#{singularize}_ancestor_pairs"
|
|
59
65
|
anchor = db[table].select(Sequel[:id].as(:descendant_id), Sequel[:id].as(:ancestor_id))
|
|
60
66
|
step = db[name].join(table, id: :ancestor_id).exclude(Sequel[table][:parent_id] => nil).
|
|
61
67
|
select(Sequel[name][:descendant_id], Sequel[table][:parent_id])
|
|
62
|
-
db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id])
|
|
68
|
+
db.from(name).with_recursive(name, anchor, step, args: [:descendant_id, :ancestor_id], union_all: false)
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
# Every node paired with itself and each of its descendants, as
|
|
66
72
|
# (ancestor_id, descendant_id). Granting a role grants its whole subtree.
|
|
67
|
-
|
|
73
|
+
#
|
|
74
|
+
# `of:` (a dataset or an array of ids) restricts the anchor to those nodes,
|
|
75
|
+
# so only their subtrees are walked. Groups and roles are few and the
|
|
76
|
+
# whole table is cheap; resources are one row per protected record, and an
|
|
77
|
+
# unanchored CTE materialises every pair of the whole table once per
|
|
78
|
+
# strategy that joins it.
|
|
79
|
+
def descendant_pairs(of: nil)
|
|
68
80
|
table = pluralize
|
|
69
81
|
name = :"#{singularize}_descendant_pairs"
|
|
70
82
|
anchor = db[table].select(Sequel[:id].as(:ancestor_id), Sequel[:id].as(:descendant_id))
|
|
83
|
+
anchor = anchor.where(id: of) unless of.nil?
|
|
71
84
|
step = db[name].join(table, parent_id: :descendant_id).
|
|
72
85
|
select(Sequel[name][:ancestor_id], Sequel[table][:id])
|
|
73
|
-
db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id])
|
|
86
|
+
db.from(name).with_recursive(name, anchor, step, args: [:ancestor_id, :descendant_id], union_all: false)
|
|
74
87
|
end
|
|
75
88
|
|
|
76
89
|
def cte(id = nil, direction = :desc)
|
data/lib/super_auth/railtie.rb
CHANGED
|
@@ -16,6 +16,15 @@ module SuperAuth
|
|
|
16
16
|
load "tasks/super_auth_tasks.rake"
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
# Rails 7.1+ keeps one deprecator per library and applies
|
|
20
|
+
# config.active_support.deprecation / report_deprecations to each of
|
|
21
|
+
# them in the active_support.deprecation_behavior initializer, which
|
|
22
|
+
# runs after load_environment_config — so registration has to come
|
|
23
|
+
# before that, as Rails' own railties do.
|
|
24
|
+
initializer "super_auth.deprecator", before: :load_environment_config do |app|
|
|
25
|
+
app.deprecators[:super_auth] = SuperAuth.deprecator if app.respond_to?(:deprecators)
|
|
26
|
+
end
|
|
27
|
+
|
|
19
28
|
initializer "super_auth.initialize" do
|
|
20
29
|
if defined?(ActiveRecord) && defined?(ActiveRecord::Base)
|
|
21
30
|
SuperAuth.db
|
data/lib/super_auth/resource.rb
CHANGED
|
@@ -1,2 +1,59 @@
|
|
|
1
1
|
class SuperAuth::Resource < Sequel::Model(:super_auth_resources)
|
|
2
|
+
# Resources nest like groups and roles: a grant on a node reaches the node
|
|
3
|
+
# and every node under it (SuperAuth::Edge.join_resource_subtree). No
|
|
4
|
+
# unrestrict_primary_key, unlike those two: it exists for the ActiveRecord
|
|
5
|
+
# twins' descendants_dataset, which builds a Sequel model with an id, and
|
|
6
|
+
# the ActiveRecord Resource deliberately has none — nothing at runtime
|
|
7
|
+
# walks a resource tree.
|
|
8
|
+
include SuperAuth::Nestable
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
# A node with an external_type and no external_id is a type-level
|
|
12
|
+
# (wildcard) node: at runtime it means every record of that type, present
|
|
13
|
+
# and future (the ByCurrentUser type_level branch, the policy's
|
|
14
|
+
# `resource_external_id IS NULL OR` clause). A node with neither is a
|
|
15
|
+
# container. Wildcards are deprecated (see warn_deprecated_wildcards) but
|
|
16
|
+
# still the only way to authorize INSERT under row-level security, so they
|
|
17
|
+
# stay; what they may not do is join the tree.
|
|
18
|
+
def wildcards
|
|
19
|
+
exclude(external_type: nil).where(external_id: nil)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# "Wildcard nodes are flat." compile! calls this before touching the
|
|
23
|
+
# compiled table, so a refused compile leaves the previous rows in place.
|
|
24
|
+
# A wildcard with a parent would compile to a (type, NULL) row reachable
|
|
25
|
+
# through every ancestor's grants — one edge to a container silently
|
|
26
|
+
# granting every record of a type — and a wildcard with children would
|
|
27
|
+
# make the children unreachable except through a grant that already covers
|
|
28
|
+
# them; neither is a shape anyone means. One query: the wildcards that
|
|
29
|
+
# have a parent, or that some node names as its parent.
|
|
30
|
+
def assert_compilable!
|
|
31
|
+
parents = dataset.exclude(parent_id: nil).select(:parent_id)
|
|
32
|
+
nested = wildcards.where(Sequel.|(Sequel.~(parent_id: nil), { id: parents })).select_order_map(:id)
|
|
33
|
+
return if nested.empty?
|
|
34
|
+
|
|
35
|
+
raise SuperAuth::Error, "Wildcard resource nodes must be flat, but wildcard node(s) #{nested.join(', ')} " \
|
|
36
|
+
"have a parent or children. A resource node with an external_type and no external_id is a wildcard " \
|
|
37
|
+
"for every record of that type, not a container: nested in the tree it would compile to a row that " \
|
|
38
|
+
"reaches every record of its type through the tree. Move each to the root with no children, or give " \
|
|
39
|
+
"it an external_id."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One warning per compile, naming what exists, through SuperAuth.deprecator
|
|
43
|
+
# so a Rails host's deprecation config (notify, raise, silence) applies.
|
|
44
|
+
# A no-op when there are none, which is the common case.
|
|
45
|
+
def warn_deprecated_wildcards
|
|
46
|
+
rows = wildcards.order(:id).select_map([:id, :name])
|
|
47
|
+
return if rows.empty?
|
|
48
|
+
|
|
49
|
+
listed = rows.first(10).map { |id, name| "#{name} (#{id})" }
|
|
50
|
+
listed << "..." if rows.size > 10
|
|
51
|
+
SuperAuth.deprecator.warn(
|
|
52
|
+
"#{rows.size} type-level (wildcard) resource node#{'s' if rows.size > 1} " \
|
|
53
|
+
"(external_type set, external_id NULL): #{listed.join(', ')}. Wildcard nodes are deprecated. " \
|
|
54
|
+
"They still work, and they remain the only way to authorize INSERT under row-level security; " \
|
|
55
|
+
"the successor is a grant on a parent record. See the CHANGELOG."
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
2
59
|
end
|
data/lib/super_auth/version.rb
CHANGED
data/lib/super_auth.rb
CHANGED
|
@@ -4,10 +4,41 @@ require "sequel"
|
|
|
4
4
|
module SuperAuth
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
|
+
# Stand-in for ActiveSupport::Deprecation when ActiveSupport is not loaded:
|
|
8
|
+
# the same two methods the gem calls. Plain Kernel.warn rather than
|
|
9
|
+
# `category: :deprecated`, which Ruby hides unless -W:deprecated is set,
|
|
10
|
+
# and a warning nobody sees is not a deprecation.
|
|
11
|
+
class Deprecator
|
|
12
|
+
attr_accessor :silenced
|
|
13
|
+
|
|
14
|
+
def warn(message)
|
|
15
|
+
return if silenced
|
|
16
|
+
Kernel.warn "DEPRECATION WARNING: #{message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
7
20
|
def self.setup
|
|
8
21
|
yield self if block_given?
|
|
9
22
|
end
|
|
10
23
|
|
|
24
|
+
# Where the gem's deprecation warnings go. An ActiveSupport::Deprecation
|
|
25
|
+
# when ActiveSupport is loaded, so a Rails host's
|
|
26
|
+
# config.active_support.deprecation applies once the railtie registers it
|
|
27
|
+
# under app.deprecators; otherwise the stand-in above. Both answer
|
|
28
|
+
# `silenced = true`. Memoized, so that setting survives.
|
|
29
|
+
def self.deprecator
|
|
30
|
+
@deprecator ||=
|
|
31
|
+
if defined?(ActiveSupport::Deprecation)
|
|
32
|
+
ActiveSupport::Deprecation.new("1.0", "SuperAuth")
|
|
33
|
+
else
|
|
34
|
+
Deprecator.new
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.deprecator=(deprecator)
|
|
39
|
+
@deprecator = deprecator
|
|
40
|
+
end
|
|
41
|
+
|
|
11
42
|
# Controls behavior when SuperAuth.current_user is blank in ByCurrentUser scope.
|
|
12
43
|
# :none (default) — returns an empty result set silently
|
|
13
44
|
# :raise — raises SuperAuth::Error
|
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.8.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonathan Frias
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- USAGE.md
|
|
57
57
|
- config/routes.rb
|
|
58
58
|
- db/migrate/10_add_super_auth_label_to_resources.rb
|
|
59
|
+
- db/migrate/11_add_parent_id_to_resources.rb
|
|
59
60
|
- db/migrate/1_users.rb
|
|
60
61
|
- db/migrate/2_groups.rb
|
|
61
62
|
- db/migrate/3_permissions.rb
|
|
@@ -74,6 +75,7 @@ files:
|
|
|
74
75
|
- db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb
|
|
75
76
|
- db/migrate_activerecord/20250101000009_add_by_current_user_index_to_super_auth_authorizations.rb
|
|
76
77
|
- db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb
|
|
78
|
+
- db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb
|
|
77
79
|
- db/seeds/sample_data.rb
|
|
78
80
|
- exe/super_auth-editor
|
|
79
81
|
- lib/basic_loader.rb
|