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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce52cd6a9867c21405fe203e87a765372b5b0febd49109286711c7e546c5118a
4
- data.tar.gz: 1b67868c607adfa20b6832eb08e7bbf5ecaabe468a92a7ca4a8e408a8f3f2316
3
+ metadata.gz: 0f41f8bbae64f5e5ddf3666026d00a64cbe4007037b2c859152b7b3c2e331023
4
+ data.tar.gz: 6e8b002d7a9e23787565e85494c23b1a1f912770ad800b7a1986a23188701d6e
5
5
  SHA512:
6
- metadata.gz: '0582f16e103f0a1a00e7ac77edc49a3ba35f59cd0d0c98f4a98f1529c6452ca2adad7f1543df9251e923a8c805a0bce04a95c3fe17d8be86b02063b7b578b1a4'
7
- data.tar.gz: f28dedeb421ee5ea689914429e5dac616f8f3a5efa99272b2e4aea2c715c5c880670936483bcb4cfe8f7e5344cff9c72a917296d6cbdb2a2d92213067a09ac29
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- super_auth (0.7.0)
4
+ super_auth (0.8.0)
5
5
  sequel
6
6
 
7
7
  GEM
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 role trees are recursive
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); click any record to trace what it can reach and what reaches
32
- it; connect two records to draw an edge; delete records and edges; recompile. It ships
33
- in the gem as a Rack app and a command.
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 on id. Any object with an `id`
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 nests within Group,
277
- | v | v Role nests within Role)
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 `Role` mean each nests within itself: a `Group` can contain
296
- child `Group`s and a `Role` can contain child `Role`s, recursively. Grants on a parent
297
- flow to every descendant which is why `Group` and `Role` are described as *trees*.
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 `Role` are used, the rules will apply to all descedants. If there are any edges
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
- name: "restartable servers",
438
- external_type: "Resource::ResourceRestartPermission"
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`) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
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) | No |
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. They can link to your app's models.
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
- # Named resource
253
+ # A named resource with nothing behind it
254
254
  staging = SuperAuth::Resource.create(name: "staging")
255
255
 
256
- # Linked to an ActiveRecord model
257
- posts = SuperAuth::Resource.create(
258
- name: "posts",
259
- external_id: nil,
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 your app's Post model
539
+ # Link a SuperAuth resource to one Post
479
540
  sa_resource = SuperAuth::Resource.create(
480
- name: "posts",
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); click any record to trace what it can reach and what reaches
542
- it, connect records to draw edges, delete records and edges, and recompile. See the
543
- README's "Graph editor" section for the full description.
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", external_type: "API")
581
- dashboard = SuperAuth::Resource.create(name: "dashboard", external_type: "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
@@ -29,6 +29,7 @@ puts "Clearing existing data..."
29
29
  Edge.delete_all
30
30
  Group.update_all(parent_id: nil)
31
31
  Role.update_all(parent_id: nil)
32
+ Resource.update_all(parent_id: nil)
32
33
  User.delete_all
33
34
  Group.delete_all
34
35
  Role.delete_all
@@ -4,11 +4,13 @@ SuperAuth initializer created at config/initializers/super_auth.rb
4
4
 
5
5
  Next steps:
6
6
 
7
- 1. Run migrations to create the database tables:
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 adds its ActiveRecord migrations to your app)
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 type-level rows
37
- # (external_id IS NULL) have no record to name at all. Writing nil for any
38
- # of them would turn "this label is stale" into data, which is the failure
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
@@ -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
- join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], id: Sequel[:permission_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], id: Sequel[:resource_edges][:resource_id]).
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
- join(Sequel[:super_auth_resources], Sequel[:user_edges][:resource_id] => Sequel[:super_auth_resources][:id]).
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 1fr;grid-template-rows:1fr;
57
- grid-template-areas:"resource user2"}
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. Group/role hierarchy is bidirectional
161
- // (a parent's grants apply to children, and a parent "contains" its children).
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="external record">${ref}</span>`);
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
- production_cluster = res_m.create(name: "production_cluster")
72
- staging_cluster = res_m.create(name: "staging_cluster")
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
- edg.create(permission_id: deploy.id, resource_id: production_cluster.id)
103
- edg.create(permission_id: deploy.id, resource_id: staging_cluster.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
@@ -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, client-side traversal, node and edge CRUD, and a Recompile button.
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] pairs, outermost first, puts every child under its
126
- # own parent and leaves siblings alphabetical. Both node sets are small
127
- # enough to order in Ruby, and the client's depthOf is unaffected.
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
- return json(422, error: "parent not found") unless model[parent.to_i]
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
 
@@ -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
- def descendant_pairs
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)
@@ -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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module SuperAuth
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end
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.7.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