super_auth 0.4.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +132 -49
  6. data/USAGE.md +98 -31
  7. data/config/routes.rb +9 -71
  8. data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
  9. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  10. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  11. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  12. data/db/seeds/sample_data.rb +1 -0
  13. data/exe/super_auth-editor +9 -0
  14. data/lib/generators/super_auth/install/templates/README +18 -11
  15. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  16. data/lib/super_auth/active_record/authorization.rb +7 -0
  17. data/lib/super_auth/active_record/by_current_user.rb +1 -1
  18. data/lib/super_auth/active_record/resource.rb +45 -0
  19. data/lib/super_auth/active_record/user.rb +3 -1
  20. data/lib/super_auth/authorization.rb +24 -0
  21. data/lib/super_auth/edge.rb +54 -18
  22. data/lib/super_auth/editor/cli.rb +91 -0
  23. data/lib/super_auth/editor/index.html +430 -0
  24. data/lib/super_auth/editor/seed.rb +176 -0
  25. data/lib/super_auth/editor.rb +288 -0
  26. data/lib/super_auth/nestable.rb +16 -3
  27. data/lib/super_auth/railtie.rb +9 -2
  28. data/lib/super_auth/resource.rb +57 -0
  29. data/lib/super_auth/rls.rb +164 -34
  30. data/lib/super_auth/user.rb +3 -1
  31. data/lib/super_auth/version.rb +1 -1
  32. data/lib/super_auth.rb +73 -5
  33. data/lib/tasks/super_auth_tasks.rake +28 -0
  34. metadata +13 -8
  35. data/VISUALIZATION.md +0 -58
  36. data/app/controllers/super_auth/graph_controller.rb +0 -654
  37. data/app/views/super_auth/graph/index.html.erb +0 -1408
  38. data/super_auth.gemspec +0 -35
  39. data/visualization.html +0 -747
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d15415f81b29b08a690f0223fe01fd888177085f479adb902ab5103002e7292
4
- data.tar.gz: 6bc43afccfc699cbf2f8d67c6af32226cfa8989f2e188bbe919835529b488a43
3
+ metadata.gz: 0f41f8bbae64f5e5ddf3666026d00a64cbe4007037b2c859152b7b3c2e331023
4
+ data.tar.gz: 6e8b002d7a9e23787565e85494c23b1a1f912770ad800b7a1986a23188701d6e
5
5
  SHA512:
6
- metadata.gz: 8fc62a59a8291668b01d3a4a012422f764302534f5acc7be6d5954e2dfa4427cd69d7b5f81d65fb96a9be77f1c3c328ce4785071ba2310e7c2cbecf1c1174f9f
7
- data.tar.gz: c5012e5e5fdebb90c46326ed8438e939b4c37562730aa33078eecc9dd74debb07c0154509c90549c4dbc783aa4f14e7d2353e63d814571e22e018f45439d3991
6
+ metadata.gz: bde433eba068b2b55edad37701dac014340a5e840e26322238760d554f1e44e8aabc31d934ed776207f02439563ffb52083195cbf99207807ac8e0b23dbf7b5a
7
+ data.tar.gz: eb3456e6855c070931b5488fd8632eb60d74305753ea007d346f47a11f257aec2104d83061dafdea09f43ab7e90cddfaf4aaf69be236010f2f0896a3821242b0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,95 @@
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
+
36
+ ## [0.7.0] - 2026-09-07
37
+
38
+ ### Fixed
39
+
40
+ - The editor's Groups and Roles boxes drew a flat list ordered by name and faked the hierarchy with indentation, so a child appeared nested under whichever unrelated node happened to sort directly above it — a group named `org:451ed5a8…` sorts between "Lawyers" and "Organizations" and was drawn under Lawyers, while its real parent rendered below its own child. Group grants flow to members of descendant groups, so "which parent does this hang under" is the question the editor is opened to answer, and it was answering it wrong. `/api/graph` now returns nested types ordered by ancestor name path, so each child follows its own parent with siblings alphabetical among themselves. Display only: no `parent_id` changes, and the client's `depthOf` is untouched. The order stays total for a broken tree — a dangling `parent_id` sorts as a root, and a parent cycle terminates rather than hanging the sort.
41
+
42
+ ### Added
43
+
44
+ - `super_auth_resources.super_auth_label`, a nullable column holding the human name of the application record a resource node points at, so the editor can render "Gulf War presumptive" where it rendered `Claim#3a00b6fa-2998-41ba-953f-a3b0de1876b3`. The label is stored rather than resolved at render time for three reasons: the editor reads bare Sequel models with no association to the application, `super_auth-editor` serves the same UI against a bare `SUPER_AUTH_DATABASE_URL` with no application loaded at all, and RLS makes exactly the largest protected resource types unreadable without an asserted identity — a live lookup would return empty labels for those and full labels for everything else, which reads as data rather than as a missing permission. The column carries the prefix for the same reason the opt-in method does: `label` is a name applications want for themselves. Migration 10 in both `db/migrate/` and `db/migrate_activerecord/`.
45
+ - `SuperAuth.label_for(record)` derives that name by convention rather than configuration: `super_auth_label` if the model defines it, then `name`, then `title`. Never `to_s` — the label sits where `Type#id` otherwise renders, and `#<Claim:0x000055…>` is worse than the id it would replace.
46
+ - `SuperAuth::ActiveRecord::Resource` derives its label on save, so a host that already syncs resource nodes gets labels with no extra wiring, and `#refresh_label!` re-derives it after the application record is renamed, which does not write the node. A nil derivation never overwrites a stored label: RLS blanking the record, an `external_type` naming a class this process has not loaded, and a type-level row (`external_id IS NULL`) all derive nil, and none of them means the record has no name.
47
+ - The editor renders the label in the slot that held `Type#id`, demoting `Type#id` to the tooltip, and falls back to today's rendering for a node without one. Its per-box filter now matches everything a row carries — name, label, `external_type` and `external_id` — so a pasted uuid finds its node, which it never did before: the filter only ever looked at `name`.
48
+ - `rake super_auth:labels:backfill` labels existing rows and repairs drift. It asserts the system identity where RLS is installed, because the application tables it reads are the ones RLS protects — run without an identity it would derive nil for the rows that matter most and report success.
49
+
50
+ A stored label is a snapshot. It drifts between the application record being renamed and the next `refresh_label!` or backfill, and that is the trade: a stale or missing label degrades to the `Type#id` the editor rendered before, never to something wrong. `super_auth_users.name` has always been a denormalized label of the same kind and never refreshes at all, so this is the existing idea finished rather than a new one.
51
+
52
+ Users are out of scope: `super_auth_users.name` already carries a human name, so user nodes already read as "Jonathan Frias" in the editor. Groups, roles and permissions encode their application link in host-specific name conventions and are a separate job.
53
+
54
+ ## [0.6.0] - 2026-09-07
55
+
56
+ ### Added
57
+
58
+ - `SuperAuth::RLS.assert(user)` asserts the database identity in the transaction the caller already holds and opens nothing: the `SELECT super_auth_become(...)` half of the SQL contract, or `super_auth_system()` for a system user. For code that changes user mid-transaction or manages its own transaction; `SuperAuth.as` is now this plus a transaction plus restore.
59
+ - `SuperAuth::RLS.installed?` reports whether `enable` has run on the database, so an application that degrades when RLS is absent no longer probes for the SQL functions by signature. A probe written against one signature returns false when the signature changes and turns row-level security off with a green suite, which is what a 0.4.0 probe does against 0.5.0.
60
+
61
+ ### Changed
62
+
63
+ - `SuperAuth.as` assigns `SuperAuth.current_user` inside the transaction, after the database identity is asserted, so an application that hooks the writer to re-assert does so on the transaction's connection instead of once outside it and once in.
64
+
65
+ ### Removed
66
+
67
+ - `on_error:` on `SuperAuth.as` and `SuperAuth::RLS.as`, added in 0.5.0. Whether a write survives the block raising is the caller's decision, not the identity wrapper's: rescue inside the block to keep it, let the exception out to roll it back. `as` has one control-flow path again.
68
+
69
+ ## [0.5.0] - 2026-09-07
70
+
71
+ ### Security
72
+
73
+ - Row-level security: the system bypass is no longer a parameter of `super_auth_become()`. It moved to a separate function, `super_auth_system()`, whose `EXECUTE` privilege `enable` revokes from `PUBLIC`, so the right to bypass every policy is granted per role (`GRANT EXECUTE ON FUNCTION super_auth_system() TO <role>`) instead of coming with the right to assert an identity. `SuperAuth.as(user)` calls it for users whose `system?` is true. Both functions now raise if the calling role is a superuser or has `BYPASSRLS`, because Postgres exempts those roles from row security and the assertion would protect nothing. Breaking for clients that call the SQL directly: `super_auth_become(...)` takes three arguments and the four-argument overload is dropped on the next `enable`; roles that bypass need the grant above.
74
+
75
+ ### Added
76
+
77
+ - A graph editor, Rails-free, shipped as a mountable Rack app (`SuperAuth::Editor`, `require "super_auth/editor"`) and a command (`super_auth-editor`) that serves it on loopback against `SUPER_AUTH_DATABASE_URL`, with `--migrate` and `--seed` as explicit, opt-in steps. Five boxes with client-side traversal, node and edge CRUD, and a Recompile button. It has no authentication of its own: mount it inside yours. Writes must be JSON, only the eight edge kinds the path strategies read can be created, and the command rejects foreign `Host` headers.
78
+ - `SuperAuth::Authorization.compile!` for applications without ActiveRecord, and `POST /api/compile` in the editor. Runtime enforcement reads only the compiled table, so every graph edit is inert until it runs.
79
+
80
+ ### Fixed
81
+
82
+ - `ByCurrentUser` now recognises a Sequel `SuperAuth::User` as an internal user and matches it on `user_id`, as the RLS policies already did; before, it was treated as an external object and silently saw nothing. Both layers share `SuperAuth.internal_user?`.
83
+
84
+ ### Removed
85
+
86
+ - The d3 graph visualizer: `SuperAuth::GraphController`, its view, its JSON API (`/graph/data`, `/graph/authorize`, `/graph/orphaned`, `/graph/compile_authorizations`, and the `/graph/*` create and delete routes), `visualization.html`, and `VISUALIZATION.md`. `mount SuperAuth::Engine => "/super_auth"` now serves the graph editor at that path; it has no authentication of its own, so mount it inside yours. Anything that called the old JSON routes must move to the editor's API or to the models.
87
+
88
+ ### Changed
89
+
90
+ - `SuperAuth.as(user)` now carries both identities: it sets `SuperAuth.current_user` (read by the `ByCurrentUser` scope) for the block as well as asserting the database identity (read by the RLS policies), and restores both on the way out, on return, on raise, and when nested. `SuperAuth::RLS.as` stays the pure SQL-contract wrapper and now restores the enclosing database identity when it is nested inside a transaction, so an inner block can no longer leave the outer one running as its user. Inside a transaction the caller opened, `as` joins it. One option replaces the wrapper an application used to need: `auto_savepoint: true` makes every nested transaction a savepoint (the ActiveRecord bridge turns it into `joinable: false`), so a save inside the block commits on its own and its `after_commit` hooks fire then. Other keyword options pass through to Sequel's `transaction`. Behaviour change: `SuperAuth.as(nil)` runs the block with `current_user = nil`, so apps with `missing_user_behavior = :raise` now raise on scoped queries inside an anonymous block instead of inheriting whatever the thread-local held before. Clients that probe for the identity function must look for `super_auth_become(text, text, text)`; the four-argument signature is gone (see Security above).
91
+ - `SuperAuth::RLS.enable` grants `SELECT` on `super_auth_authorizations` and `super_auth_users` to `PUBLIC`, so a runtime role needs privileges on the application's tables and nothing else; `SuperAuth::RLS.grant_system(role)` hands out the bypass without hand-written SQL. `system?` on both user models is now a read-only lookup (`.system` still creates the row), so passing SuperAuth user records to `SuperAuth.as` never needs `INSERT`.
92
+
3
93
  ## [0.4.0] - 2026-09-02
4
94
 
5
95
  ### Security
data/Gemfile CHANGED
@@ -15,4 +15,9 @@ group :development, :test do
15
15
  gem "activerecord"
16
16
  gem "sequel-activerecord_connection"
17
17
  gem "after_commit_everywhere"
18
+ # Rack::MockRequest and Rack::Lint for the editor specs, and a server for
19
+ # exe/super_auth-editor. The gem itself depends on none of them.
20
+ gem "rack"
21
+ gem "rackup"
22
+ gem "webrick"
18
23
  end
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- super_auth (0.4.0)
4
+ super_auth (0.8.0)
5
5
  sequel
6
6
 
7
7
  GEM
@@ -54,6 +54,9 @@ GEM
54
54
  coderay (~> 1.1)
55
55
  method_source (~> 1.0)
56
56
  reline (>= 0.6.0)
57
+ rack (3.2.7)
58
+ rackup (2.3.1)
59
+ rack (>= 3)
57
60
  rake (13.3.1)
58
61
  reline (0.6.3)
59
62
  io-console (~> 0.5)
@@ -82,6 +85,7 @@ GEM
82
85
  tzinfo (2.0.6)
83
86
  concurrent-ruby (~> 1.0)
84
87
  uri (1.1.1)
88
+ webrick (1.9.2)
85
89
  zeitwerk (2.7.5)
86
90
 
87
91
  PLATFORMS
@@ -96,12 +100,15 @@ DEPENDENCIES
96
100
  mysql2
97
101
  pg
98
102
  pry
103
+ rack
104
+ rackup
99
105
  rake (~> 13.0)
100
106
  rspec (~> 3.0)
101
107
  sequel
102
108
  sequel-activerecord_connection
103
109
  sqlite3
104
110
  super_auth!
111
+ webrick
105
112
  zeitwerk (~> 2.6)
106
113
 
107
114
  BUNDLED WITH
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
@@ -25,34 +25,62 @@ suite against each of the three. Row-level security is Postgres only.
25
25
  How `super_auth` stacks up against other authentication strategies:
26
26
  [Do you really understand Authorization](https://dev.to/jonathanfrias/do-you-really-understand-authorization-1o5d)
27
27
 
28
- ## Graph Visualization
28
+ ## Graph editor
29
29
 
30
- SuperAuth includes an interactive graph visualization tool to help you understand and debug your authorization rules!
30
+ A Rails-free editor for the authorization graph: five boxes (groups, roles, users,
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.
31
38
 
32
- ![SuperAuth Visualization](https://img.shields.io/badge/Visualization-Interactive-brightgreen)
33
-
34
- See the complete authorization graph with:
35
- - Color-coded nodes (Users, Groups, Roles, Permissions, Resources)
36
- - Interactive path finding
37
- - Real-time authorization queries
38
- - Example scenarios from the README
39
+ ```bash
40
+ gem install super_auth rackup webrick # any Rack server works; puma too
41
+ SUPER_AUTH_DATABASE_URL=postgres://user:password@localhost/app_development super_auth-editor
42
+ ```
39
43
 
40
- **Quick Start:**
44
+ Then open http://127.0.0.1:4666. Options: `--host` (default `127.0.0.1`), `--port`
45
+ (default `4666`), `--migrate` (run the gem's Sequel migrations first, for a database that
46
+ has none), `--seed` (replace the whole graph with a sample company, destructive).
41
47
 
42
- ```bash
43
- # 1. Generate initializer
44
- rails generate super_auth:install
48
+ > ⚠️ **The editor has no authentication.** Anyone who can reach it can rewrite the graph.
49
+ > The command binds to loopback and rejects requests whose `Host` header is not
50
+ > localhost. When you mount the app in your own server, put your own authentication in
51
+ > front of it, as below.
45
52
 
46
- # 2. Mount the engine in config/routes.rb
47
- mount SuperAuth::Engine => '/super_auth'
53
+ In Rails the engine serves it. Mount the engine inside your own authentication:
48
54
 
49
- # 3. Load sample data (optional)
50
- rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
55
+ ```ruby
56
+ # config/routes.rb
57
+ authenticate :admin do # Devise; or a constraints block
58
+ mount SuperAuth::Engine => "/super_auth"
59
+ end
51
60
  ```
52
61
 
53
- Then visit: `http://localhost:3000/super_auth/visualization`
62
+ Or mount the app itself anywhere: `require "super_auth/editor"` and
63
+ `mount SuperAuth::Editor => "/wherever"`, again inside your authentication.
54
64
 
55
- See [VISUALIZATION.md](VISUALIZATION.md) for complete documentation.
65
+ Mount it in any Rack app:
66
+
67
+ ```ruby
68
+ # config.ru
69
+ SuperAuth.db = Sequel.connect(ENV.fetch("SUPER_AUTH_DATABASE_URL"))
70
+ SuperAuth.load
71
+ map "/super_auth/editor" do
72
+ use Rack::Auth::Basic { |user, password| user == "admin" && password == ENV.fetch("EDITOR_PASSWORD") }
73
+ run SuperAuth::Editor
74
+ end
75
+ ```
76
+
77
+ Edits change the graph, not runtime access: `ByCurrentUser` and the row-level security
78
+ policies read the compiled `super_auth_authorizations` table. The strip at the top shows
79
+ that table's row count, and **Recompile** runs `SuperAuth::Authorization.compile!`
80
+ (`POST /api/compile`). The API is small and JSON: `GET /api/graph`,
81
+ `POST /api/nodes/:type`, `DELETE /api/nodes/:type/:id`, `POST /api/edges`,
82
+ `DELETE /api/edges/:id`, `POST /api/compile`. Writes must be `application/json`, and
83
+ the editor only creates edges of the eight kinds the path strategies read.
56
84
 
57
85
  ## Postgres Row-Level Security (optional)
58
86
 
@@ -77,8 +105,24 @@ SELECT super_auth_become(user_external_id => '42', user_external_type => 'AppUse
77
105
  COMMIT; -- identity dies with the transaction; there is nothing to clear
78
106
  ```
79
107
 
80
- For a user managed inside super_auth, pass `user_id => '7'` instead; `system => true`
81
- bypasses the policies (migrations, seeds, admin jobs).
108
+ For a user managed inside super_auth, pass `user_id => '7'` instead.
109
+
110
+ System context, which bypasses the policies (migrations, seeds, admin jobs), is a
111
+ separate function, so the right to bypass is granted per role rather than coming with
112
+ the right to assert an identity:
113
+
114
+ ```sql
115
+ BEGIN;
116
+ SELECT super_auth_system();
117
+ -- every protected row is visible and writable --
118
+ COMMIT;
119
+ ```
120
+
121
+ `enable` revokes `EXECUTE` on `super_auth_system()` from `PUBLIC`; a role without an
122
+ explicit grant (`SuperAuth::RLS.grant_system(role)`) gets `permission denied`. Both
123
+ functions raise if the calling role is a superuser or has `BYPASSRLS`: Postgres exempts
124
+ those roles from every policy, so the assertion would protect nothing while looking
125
+ like it does.
82
126
 
83
127
  The assertion is anchored to the calling transaction: `super_auth_become` sets
84
128
  transaction-local identity settings plus a stamp of the current transaction id, and
@@ -121,16 +165,28 @@ class name when you use the AR integration).
121
165
 
122
166
  **3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
123
167
  policies entirely, so the app must not connect as one (owning the tables is fine —
124
- the policies use `FORCE ROW LEVEL SECURITY`). The role needs `SELECT` on
125
- `super_auth_authorizations`, which the policies read; `EXECUTE` on
126
- `super_auth_become` is granted to `PUBLIC` by default, so no extra grant is needed:
168
+ the policies use `FORCE ROW LEVEL SECURITY`); both identity functions refuse such a
169
+ role outright. `enable` grants every role what it needs on the gem's own tables
170
+ (`SELECT` on `super_auth_authorizations` and `super_auth_users`; `super_auth_become`
171
+ is executable by `PUBLIC`), so a runtime role needs privileges on your tables and
172
+ nothing else:
127
173
 
128
174
  ```sql
129
175
  CREATE ROLE app_runtime LOGIN PASSWORD '...';
130
176
  GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
131
- GRANT SELECT ON super_auth_authorizations TO app_runtime;
132
177
  ```
133
178
 
179
+ The right to bypass the policies is separate. Grant it, from a migration or a
180
+ console, only to the roles that run migrations, seeds and admin jobs:
181
+
182
+ ```ruby
183
+ SuperAuth::RLS.grant_system(:app_admin) # GRANT EXECUTE ON FUNCTION super_auth_system() TO app_admin
184
+ ```
185
+
186
+ To keep the gem's tables readable only by specific roles instead, `REVOKE SELECT ON
187
+ super_auth_authorizations, super_auth_users FROM PUBLIC` and grant per role; the
188
+ policies run as the querying role, so it must keep that `SELECT`.
189
+
134
190
  > ⚠️ **This is the one step that, if skipped, silently disables all protection.**
135
191
  > PostgreSQL *always* lets **superusers** and roles with the **`BYPASSRLS`** attribute
136
192
  > bypass row-level security. `FORCE ROW LEVEL SECURITY` only subjects the table *owner*
@@ -138,8 +194,9 @@ GRANT SELECT ON super_auth_authorizations TO app_runtime;
138
194
  > Postgres as a superuser (the default in many local setups and some managed hosts), the
139
195
  > policies apply to nobody and every row stays visible, while everything *looks* like it
140
196
  > is working. Always connect as a dedicated non-superuser, non-`BYPASSRLS` role such as
141
- > `app_runtime` above. SuperAuth's language-specific clients check this on startup and
142
- > warn you when the connection is able to bypass RLS.
197
+ > `app_runtime` above. `super_auth_become()` and `super_auth_system()` refuse to run for
198
+ > such a role, so a misconfigured connection fails on its first identity assertion
199
+ > instead of silently seeing everything.
143
200
 
144
201
  **4. Wrap work in an identity assertion.** In Ruby:
145
202
 
@@ -149,11 +206,25 @@ SuperAuth.as(current_user) do
149
206
  end
150
207
  ```
151
208
 
152
- `SuperAuth.as` opens a transaction and calls `super_auth_become` for you use it in
153
- an `around_action` (or around a job) to cover a whole request. Non-Ruby apps use the
154
- SQL contract directly. Each policy checks `super_auth_authorizations` with the same
209
+ `SuperAuth.as` sets `SuperAuth.current_user` for the block as well, so the
210
+ `ByCurrentUser` scope and the policies agree, and restores both on the way out, nested
211
+ calls included. It opens a transaction and calls `super_auth_become` for you, or joins
212
+ the transaction you are already in — use it in an `around_action` (or around a job) to
213
+ cover a whole request. `auto_savepoint: true` makes every nested transaction a
214
+ savepoint (ActiveRecord's `joinable: false`), so each save inside commits on its own
215
+ and its `after_commit` hooks fire then; other keyword options pass through to Sequel's
216
+ `transaction`. Whether a write survives the block raising is up to you: rescue inside
217
+ the block to keep it. Where there is no block to wrap, a transaction you already
218
+ manage or a change of user mid-request, `SuperAuth::RLS.assert(user)` asserts the
219
+ identity in the current transaction and nothing else, and `SuperAuth::RLS.installed?`
220
+ reports whether `enable` has run, so no application needs to know the SQL functions'
221
+ signatures. Non-Ruby apps use the SQL contract directly. Each policy checks `super_auth_authorizations` with the same
155
222
  semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
156
- act as a wildcard, per-record authorizations match on id, `system?` users bypass.
223
+ act as a wildcard (deprecated, see CHANGELOG 0.8.0), per-record authorizations match
224
+ on id. Any object with an `id`
225
+ works as the user, including SuperAuth's own user records. For a user whose `system?`
226
+ is true, `SuperAuth.as` calls `super_auth_system()` instead, so the connection's role
227
+ must have been given the bypass with `SuperAuth::RLS.grant_system`.
157
228
 
158
229
  ### Notes
159
230
 
@@ -162,7 +233,11 @@ act as a wildcard, per-record authorizations match on id, `system?` users bypass
162
233
  reach protected rows.
163
234
  - Creating rows requires a type-level authorization for that resource type (or system
164
235
  context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
165
- `USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs.
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.
166
241
  - The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
167
242
  id even to read-only transactions — one extra xid per protected transaction.
168
243
  Negligible for almost everyone; revisit with a virtual-xid variant only if
@@ -207,16 +282,16 @@ The basis for how this works is that the rules engine is trying to match a user
207
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.
208
283
 
209
284
  +---+ +---+
210
- | | | | (Group nests within Group,
211
- | v | v Role nests within Role)
285
+ | | | | (Group, Role and Resource
286
+ | v | v each nest within themselves)
212
287
  +-------+ +------+
213
288
  | Group |<----->| Role |
214
289
  +-------+\ / +------+
215
290
  ^ \ / ^
216
291
  | \/ |
217
- | /\ |
218
- | / \ |
219
- V / \ V
292
+ | /\ | +---+
293
+ | / \ | | |
294
+ V / \ V | v
220
295
  +---------------+ +------+/ \+------------+ +----------+ +-------------------+
221
296
  | YourApp::User |<-->| User |<------>| Permission |<-->| Resource | <--> | YourApp::Resource |
222
297
  +---------------+ +------+ +------------+ +----------+ +-------------------+
@@ -226,9 +301,10 @@ The engine determines if it can find an authorization route betewen a user and a
226
301
 
227
302
 
228
303
  The lines between the boxes are called [edges](https://en.wikipedia.org/wiki/Glossary_of_graph_theory#edge).
229
- The self-loops on `Group` and `Role` mean each nests within itself: a `Group` can contain
230
- child `Group`s and a `Role` can contain child `Role`s, recursively. Grants on a parent
231
- flow to every descendant which is why `Group` and `Role` are described as *trees*.
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*.
232
308
 
233
309
  In general the super_auth has 5 different pathing strategies to search for access.
234
310
 
@@ -239,7 +315,7 @@ In general the super_auth has 5 different pathing strategies to search for acces
239
315
  5. users <-> resource
240
316
 
241
317
  Edges can be drawn between any 2 objects, allowing super_auth can seamlessly scale in complexity with you.
242
- 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
243
319
  between the specified user and the resource, then access is granted.
244
320
 
245
321
 
@@ -364,13 +440,18 @@ class Resource < ApplicationRecord
364
440
  end
365
441
  ```
366
442
 
367
- 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:
368
444
 
369
445
  ```ruby
370
- restartable = SuperAuth::Resource.create(
371
- name: "restartable servers",
372
- external_type: "Resource::ResourceRestartPermission"
373
- )
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
374
455
  restart = SuperAuth::Permission.create(name: "restart")
375
456
  SuperAuth::Edge.create(user: sa_user, permission: restart)
376
457
  SuperAuth::Edge.create(permission: restart, resource: restartable)
@@ -382,6 +463,8 @@ Resource::ResourceRestartPermission.find(id) # needs its own explicit approval
382
463
 
383
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.
384
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
+
385
468
  ## Row-Level Security for permission-gated models
386
469
 
387
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):
@@ -399,7 +482,7 @@ end
399
482
  # outside the block there is no asserted identity, so the policy matches nothing
400
483
  ```
401
484
 
402
- Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level wildcard grants (`resource_external_id IS NULL`) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
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.
403
486
 
404
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.
405
488