super_auth 0.4.0 → 0.7.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: 8d15415f81b29b08a690f0223fe01fd888177085f479adb902ab5103002e7292
4
- data.tar.gz: 6bc43afccfc699cbf2f8d67c6af32226cfa8989f2e188bbe919835529b488a43
3
+ metadata.gz: ce52cd6a9867c21405fe203e87a765372b5b0febd49109286711c7e546c5118a
4
+ data.tar.gz: 1b67868c607adfa20b6832eb08e7bbf5ecaabe468a92a7ca4a8e408a8f3f2316
5
5
  SHA512:
6
- metadata.gz: 8fc62a59a8291668b01d3a4a012422f764302534f5acc7be6d5954e2dfa4427cd69d7b5f81d65fb96a9be77f1c3c328ce4785071ba2310e7c2cbecf1c1174f9f
7
- data.tar.gz: c5012e5e5fdebb90c46326ed8438e939b4c37562730aa33078eecc9dd74debb07c0154509c90549c4dbc783aa4f14e7d2353e63d814571e22e018f45439d3991
6
+ metadata.gz: '0582f16e103f0a1a00e7ac77edc49a3ba35f59cd0d0c98f4a98f1529c6452ca2adad7f1543df9251e923a8c805a0bce04a95c3fe17d8be86b02063b7b578b1a4'
7
+ data.tar.gz: f28dedeb421ee5ea689914429e5dac616f8f3a5efa99272b2e4aea2c715c5c880670936483bcb4cfe8f7e5344cff9c72a917296d6cbdb2a2d92213067a09ac29
data/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-09-07
4
+
5
+ ### Fixed
6
+
7
+ - 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.
8
+
9
+ ### Added
10
+
11
+ - `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/`.
12
+ - `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.
13
+ - `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.
14
+ - 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`.
15
+ - `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.
16
+
17
+ 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.
18
+
19
+ 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.
20
+
21
+ ## [0.6.0] - 2026-09-07
22
+
23
+ ### Added
24
+
25
+ - `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.
26
+ - `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.
27
+
28
+ ### Changed
29
+
30
+ - `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.
31
+
32
+ ### Removed
33
+
34
+ - `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.
35
+
36
+ ## [0.5.0] - 2026-09-07
37
+
38
+ ### Security
39
+
40
+ - 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.
41
+
42
+ ### Added
43
+
44
+ - 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.
45
+ - `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.
46
+
47
+ ### Fixed
48
+
49
+ - `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?`.
50
+
51
+ ### Removed
52
+
53
+ - 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.
54
+
55
+ ### Changed
56
+
57
+ - `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).
58
+ - `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`.
59
+
3
60
  ## [0.4.0] - 2026-09-02
4
61
 
5
62
  ### 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.7.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
@@ -25,34 +25,58 @@ 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); 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
34
 
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
35
+ ```bash
36
+ gem install super_auth rackup webrick # any Rack server works; puma too
37
+ SUPER_AUTH_DATABASE_URL=postgres://user:password@localhost/app_development super_auth-editor
38
+ ```
39
39
 
40
- **Quick Start:**
40
+ Then open http://127.0.0.1:4666. Options: `--host` (default `127.0.0.1`), `--port`
41
+ (default `4666`), `--migrate` (run the gem's Sequel migrations first, for a database that
42
+ has none), `--seed` (replace the whole graph with a sample company, destructive).
41
43
 
42
- ```bash
43
- # 1. Generate initializer
44
- rails generate super_auth:install
44
+ > ⚠️ **The editor has no authentication.** Anyone who can reach it can rewrite the graph.
45
+ > The command binds to loopback and rejects requests whose `Host` header is not
46
+ > localhost. When you mount the app in your own server, put your own authentication in
47
+ > front of it, as below.
45
48
 
46
- # 2. Mount the engine in config/routes.rb
47
- mount SuperAuth::Engine => '/super_auth'
49
+ In Rails the engine serves it. Mount the engine inside your own authentication:
48
50
 
49
- # 3. Load sample data (optional)
50
- rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
51
+ ```ruby
52
+ # config/routes.rb
53
+ authenticate :admin do # Devise; or a constraints block
54
+ mount SuperAuth::Engine => "/super_auth"
55
+ end
51
56
  ```
52
57
 
53
- Then visit: `http://localhost:3000/super_auth/visualization`
58
+ Or mount the app itself anywhere: `require "super_auth/editor"` and
59
+ `mount SuperAuth::Editor => "/wherever"`, again inside your authentication.
60
+
61
+ Mount it in any Rack app:
62
+
63
+ ```ruby
64
+ # config.ru
65
+ SuperAuth.db = Sequel.connect(ENV.fetch("SUPER_AUTH_DATABASE_URL"))
66
+ SuperAuth.load
67
+ map "/super_auth/editor" do
68
+ use Rack::Auth::Basic { |user, password| user == "admin" && password == ENV.fetch("EDITOR_PASSWORD") }
69
+ run SuperAuth::Editor
70
+ end
71
+ ```
54
72
 
55
- See [VISUALIZATION.md](VISUALIZATION.md) for complete documentation.
73
+ Edits change the graph, not runtime access: `ByCurrentUser` and the row-level security
74
+ policies read the compiled `super_auth_authorizations` table. The strip at the top shows
75
+ that table's row count, and **Recompile** runs `SuperAuth::Authorization.compile!`
76
+ (`POST /api/compile`). The API is small and JSON: `GET /api/graph`,
77
+ `POST /api/nodes/:type`, `DELETE /api/nodes/:type/:id`, `POST /api/edges`,
78
+ `DELETE /api/edges/:id`, `POST /api/compile`. Writes must be `application/json`, and
79
+ the editor only creates edges of the eight kinds the path strategies read.
56
80
 
57
81
  ## Postgres Row-Level Security (optional)
58
82
 
@@ -77,8 +101,24 @@ SELECT super_auth_become(user_external_id => '42', user_external_type => 'AppUse
77
101
  COMMIT; -- identity dies with the transaction; there is nothing to clear
78
102
  ```
79
103
 
80
- For a user managed inside super_auth, pass `user_id => '7'` instead; `system => true`
81
- bypasses the policies (migrations, seeds, admin jobs).
104
+ For a user managed inside super_auth, pass `user_id => '7'` instead.
105
+
106
+ System context, which bypasses the policies (migrations, seeds, admin jobs), is a
107
+ separate function, so the right to bypass is granted per role rather than coming with
108
+ the right to assert an identity:
109
+
110
+ ```sql
111
+ BEGIN;
112
+ SELECT super_auth_system();
113
+ -- every protected row is visible and writable --
114
+ COMMIT;
115
+ ```
116
+
117
+ `enable` revokes `EXECUTE` on `super_auth_system()` from `PUBLIC`; a role without an
118
+ explicit grant (`SuperAuth::RLS.grant_system(role)`) gets `permission denied`. Both
119
+ functions raise if the calling role is a superuser or has `BYPASSRLS`: Postgres exempts
120
+ those roles from every policy, so the assertion would protect nothing while looking
121
+ like it does.
82
122
 
83
123
  The assertion is anchored to the calling transaction: `super_auth_become` sets
84
124
  transaction-local identity settings plus a stamp of the current transaction id, and
@@ -121,16 +161,28 @@ class name when you use the AR integration).
121
161
 
122
162
  **3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
123
163
  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:
164
+ the policies use `FORCE ROW LEVEL SECURITY`); both identity functions refuse such a
165
+ role outright. `enable` grants every role what it needs on the gem's own tables
166
+ (`SELECT` on `super_auth_authorizations` and `super_auth_users`; `super_auth_become`
167
+ is executable by `PUBLIC`), so a runtime role needs privileges on your tables and
168
+ nothing else:
127
169
 
128
170
  ```sql
129
171
  CREATE ROLE app_runtime LOGIN PASSWORD '...';
130
172
  GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
131
- GRANT SELECT ON super_auth_authorizations TO app_runtime;
132
173
  ```
133
174
 
175
+ The right to bypass the policies is separate. Grant it, from a migration or a
176
+ console, only to the roles that run migrations, seeds and admin jobs:
177
+
178
+ ```ruby
179
+ SuperAuth::RLS.grant_system(:app_admin) # GRANT EXECUTE ON FUNCTION super_auth_system() TO app_admin
180
+ ```
181
+
182
+ To keep the gem's tables readable only by specific roles instead, `REVOKE SELECT ON
183
+ super_auth_authorizations, super_auth_users FROM PUBLIC` and grant per role; the
184
+ policies run as the querying role, so it must keep that `SELECT`.
185
+
134
186
  > ⚠️ **This is the one step that, if skipped, silently disables all protection.**
135
187
  > PostgreSQL *always* lets **superusers** and roles with the **`BYPASSRLS`** attribute
136
188
  > bypass row-level security. `FORCE ROW LEVEL SECURITY` only subjects the table *owner*
@@ -138,8 +190,9 @@ GRANT SELECT ON super_auth_authorizations TO app_runtime;
138
190
  > Postgres as a superuser (the default in many local setups and some managed hosts), the
139
191
  > policies apply to nobody and every row stays visible, while everything *looks* like it
140
192
  > 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.
193
+ > `app_runtime` above. `super_auth_become()` and `super_auth_system()` refuse to run for
194
+ > such a role, so a misconfigured connection fails on its first identity assertion
195
+ > instead of silently seeing everything.
143
196
 
144
197
  **4. Wrap work in an identity assertion.** In Ruby:
145
198
 
@@ -149,11 +202,24 @@ SuperAuth.as(current_user) do
149
202
  end
150
203
  ```
151
204
 
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
205
+ `SuperAuth.as` sets `SuperAuth.current_user` for the block as well, so the
206
+ `ByCurrentUser` scope and the policies agree, and restores both on the way out, nested
207
+ calls included. It opens a transaction and calls `super_auth_become` for you, or joins
208
+ the transaction you are already in — use it in an `around_action` (or around a job) to
209
+ cover a whole request. `auto_savepoint: true` makes every nested transaction a
210
+ savepoint (ActiveRecord's `joinable: false`), so each save inside commits on its own
211
+ and its `after_commit` hooks fire then; other keyword options pass through to Sequel's
212
+ `transaction`. Whether a write survives the block raising is up to you: rescue inside
213
+ the block to keep it. Where there is no block to wrap, a transaction you already
214
+ manage or a change of user mid-request, `SuperAuth::RLS.assert(user)` asserts the
215
+ identity in the current transaction and nothing else, and `SuperAuth::RLS.installed?`
216
+ reports whether `enable` has run, so no application needs to know the SQL functions'
217
+ signatures. Non-Ruby apps use the SQL contract directly. Each policy checks `super_auth_authorizations` with the same
155
218
  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.
219
+ act as a wildcard, per-record authorizations match on id. Any object with an `id`
220
+ works as the user, including SuperAuth's own user records. For a user whose `system?`
221
+ is true, `SuperAuth.as` calls `super_auth_system()` instead, so the connection's role
222
+ must have been given the bypass with `SuperAuth::RLS.grant_system`.
157
223
 
158
224
  ### Notes
159
225
 
data/USAGE.md CHANGED
@@ -95,14 +95,18 @@ Post.all # only posts the current user can access
95
95
  Post.where(published: true) # scoped AND filtered by authorization
96
96
  ```
97
97
 
98
- **Step 6 (optional).** Mount the visualization engine:
98
+ **Step 6 (optional).** Mount the engine, which serves the graph editor. It has no
99
+ authentication of its own, so mount it inside yours:
99
100
 
100
101
  ```ruby
101
102
  # config/routes.rb
102
- mount SuperAuth::Engine => '/super_auth'
103
+ authenticate :admin do # Devise; or a constraints block
104
+ mount SuperAuth::Engine => '/super_auth'
105
+ end
103
106
  ```
104
107
 
105
- Then visit `http://localhost:3000/super_auth/visualization` to see your authorization graph.
108
+ Then open `http://localhost:3000/super_auth` to edit the graph. Edits take effect at
109
+ runtime after **Recompile** (or `SuperAuth::ActiveRecord::Authorization.compile!`).
106
110
 
107
111
  ### Standalone Setup (without Rails)
108
112
 
@@ -533,25 +537,23 @@ deployers.map { |a| a[:user_name] }.uniq
533
537
 
534
538
  ## Visualization
535
539
 
536
- SuperAuth includes an interactive graph visualization UI. After mounting the engine:
540
+ 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.
537
544
 
538
- ```ruby
539
- # config/routes.rb
540
- mount SuperAuth::Engine => '/super_auth'
541
- ```
545
+ - Rails: mount the engine inside your own authentication (Step 6 above) and open
546
+ `http://localhost:3000/super_auth`.
547
+ - Anywhere else: `super_auth-editor` serves it on loopback against
548
+ `SUPER_AUTH_DATABASE_URL`, or `require "super_auth/editor"` and mount
549
+ `SuperAuth::Editor` in any Rack app, inside your authentication.
542
550
 
543
- Load sample data (optional):
551
+ Load sample data (optional, Rails):
544
552
 
545
553
  ```bash
546
554
  rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
547
555
  ```
548
556
 
549
- Visit `http://localhost:3000/super_auth/visualization` to see:
550
-
551
- - Color-coded nodes for each entity type
552
- - Interactive path finding
553
- - Real-time authorization queries
554
-
555
557
  ## Full Example
556
558
 
557
559
  Here's a complete example modeling a company with departments, roles, and resources:
data/config/routes.rb CHANGED
@@ -1,73 +1,11 @@
1
+ require "super_auth/editor"
2
+
3
+ # The engine serves the graph editor at its mount point. Mount it inside your
4
+ # own authentication: it has none of its own.
5
+ #
6
+ # authenticate :admin do
7
+ # mount SuperAuth::Engine => "/super_auth"
8
+ # end
1
9
  SuperAuth::Engine.routes.draw do
2
- # Main graph visualization interface
3
- get '/', to: 'graph#index', as: :root
4
- get '/graph', to: 'graph#index'
5
-
6
- # Graph data API
7
- get '/graph/data', to: 'graph#data'
8
- get '/graph/orphaned', to: 'graph#orphaned'
9
- post '/graph/compile_authorizations', to: 'graph#compile_authorizations'
10
-
11
- # Authorization check
12
- get '/graph/authorize', to: 'graph#authorize'
13
-
14
- # Legacy visualization endpoint
15
- get '/visualization', to: 'graph#visualization'
16
-
17
- # CRUD operations for graph entities
18
- scope :graph do
19
- resources :users, only: [:create, :destroy], controller: 'graph' do
20
- collection do
21
- post '/', action: :create_user
22
- end
23
- member do
24
- delete '/', action: :delete_user
25
- end
26
- end
27
-
28
- resources :groups, only: [:create, :destroy], controller: 'graph' do
29
- collection do
30
- post '/', action: :create_group
31
- end
32
- member do
33
- delete '/', action: :delete_group
34
- end
35
- end
36
-
37
- resources :roles, only: [:create, :destroy], controller: 'graph' do
38
- collection do
39
- post '/', action: :create_role
40
- end
41
- member do
42
- delete '/', action: :delete_role
43
- end
44
- end
45
-
46
- resources :permissions, only: [:create, :destroy], controller: 'graph' do
47
- collection do
48
- post '/', action: :create_permission
49
- end
50
- member do
51
- delete '/', action: :delete_permission
52
- end
53
- end
54
-
55
- resources :graph_resources, only: [:create, :destroy], controller: 'graph', path: 'resources' do
56
- collection do
57
- post '/', action: :create_resource
58
- end
59
- member do
60
- delete '/', action: :delete_resource
61
- end
62
- end
63
-
64
- resources :edges, only: [:create, :destroy], controller: 'graph' do
65
- collection do
66
- post '/', action: :create_edge
67
- end
68
- member do
69
- delete '/', action: :delete_edge
70
- end
71
- end
72
- end
10
+ mount SuperAuth::Editor, at: "/"
73
11
  end
@@ -0,0 +1,13 @@
1
+ Sequel.migration do
2
+ up do
3
+ alter_table(:super_auth_resources) do
4
+ add_column :super_auth_label, String
5
+ end
6
+ end
7
+
8
+ down do
9
+ alter_table(:super_auth_resources) do
10
+ drop_column :super_auth_label
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ class AddSuperAuthLabelToSuperAuthResources < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :super_auth_resources, :super_auth_label, :string
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # Serves the super_auth graph editor on loopback. See `super_auth-editor --help`.
3
+ require "super_auth/editor/cli"
4
+
5
+ begin
6
+ SuperAuth::Editor::CLI.run(ARGV)
7
+ rescue SuperAuth::Error => e
8
+ abort "super_auth-editor: #{e.message}"
9
+ end
@@ -6,24 +6,29 @@ Next steps:
6
6
 
7
7
  1. Run migrations to create the database tables:
8
8
 
9
- SuperAuth.install_migrations
9
+ rails db:migrate
10
10
 
11
- You can run this in the Rails console or add it to a rake task.
11
+ (the engine adds its ActiveRecord migrations to your app)
12
12
 
13
- 2. Mount the engine in config/routes.rb:
13
+ 2. Mount the engine in config/routes.rb, inside your own authentication.
14
+ It serves the graph editor, which has no authentication of its own:
14
15
 
15
- mount SuperAuth::Engine => '/super_auth'
16
+ authenticate :admin do
17
+ mount SuperAuth::Engine => '/super_auth'
18
+ end
16
19
 
17
- 3. (Optional) Load sample data to see the visualization in action:
20
+ 3. (Optional) Load sample data to have a graph to look at:
18
21
 
19
22
  rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
20
23
 
21
- 4. Start your Rails server and visit:
24
+ 4. Start your Rails server and open the editor:
22
25
 
23
- http://localhost:3000/super_auth/visualization
26
+ http://localhost:3000/super_auth
24
27
 
25
- For more information, see:
26
- - VISUALIZATION.md for full documentation
27
- - README.md for usage examples
28
+ Edits change the graph; runtime access comes from the compiled table,
29
+ so press Recompile (or run SuperAuth::ActiveRecord::Authorization.compile!)
30
+ after editing.
31
+
32
+ For more information, see README.md ("Graph editor") and USAGE.md.
28
33
 
29
34
  ===============================================================================
@@ -3,6 +3,8 @@ class EnableSuperAuthRls < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
3
3
  <% model_names.each do |model| -%>
4
4
  SuperAuth::RLS.enable(:<%= model.tableize.tr('/', '_') %>, resource_type: "<%= model.camelize %>")
5
5
  <% end -%>
6
+ # Roles allowed to bypass the policies (migrations, seeds, admin jobs):
7
+ # SuperAuth::RLS.grant_system(:app_admin)
6
8
  end
7
9
 
8
10
  def down
@@ -30,7 +30,7 @@ module SuperAuth::ActiveRecord::ByCurrentUser
30
30
  self
31
31
  else
32
32
  user_where =
33
- if SuperAuth.current_user.is_a?(SuperAuth::ActiveRecord::User)
33
+ if SuperAuth.internal_user?(SuperAuth.current_user)
34
34
  { user_id: SuperAuth.current_user.id }
35
35
  else
36
36
  { user_external_id: SuperAuth.current_user.id, user_external_type: SuperAuth.current_user.class.name }
@@ -1,4 +1,45 @@
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
+
5
+ # `super_auth_label` is a stored snapshot of the application record's human
6
+ # name, so the editor can render "Gulf War presumptive" instead of
7
+ # Claim#3a00b6fa and `super_auth-editor` can do it against a bare
8
+ # SUPER_AUTH_DATABASE_URL with no application loaded. It carries the prefix
9
+ # for the same reason the opt-in method on the host's model does: `label` is
10
+ # a name applications want for themselves.
11
+ #
12
+ # Deriving it here means a host that already syncs its resources gets labels
13
+ # with no extra wiring; renames still need refresh_label!, since they do not
14
+ # write this row.
15
+ before_save :set_label, if: :external_id?
16
+
17
+ # Re-derive the label after the application record is renamed. Hosts call it
18
+ # from whatever already syncs the node; super_auth:labels:backfill calls it
19
+ # for every row.
20
+ def refresh_label!
21
+ derived = derived_label
22
+ update_column(:super_auth_label, derived) unless derived.nil?
23
+ end
24
+
25
+ private
26
+
27
+ def set_label
28
+ derived = derived_label
29
+ self.super_auth_label = derived unless derived.nil?
30
+ end
31
+
32
+ # ponytail: a nil derivation never overwrites a stored label, in either
33
+ # path, and never fails the save. Three things derive nil and none of them
34
+ # means "this record has no name": RLS makes the application record
35
+ # 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
+ def derived_label
41
+ SuperAuth.label_for(external)
42
+ rescue NameError
43
+ nil
44
+ end
4
45
  end
@@ -5,7 +5,9 @@ class SuperAuth::ActiveRecord::User < ActiveRecord::Base
5
5
 
6
6
  def model_name = ActiveModel::Name.new(:user)
7
7
 
8
- def system? = self.class.system == self
8
+ # A read: runtime roles only get SELECT on this table. `.system` creates
9
+ # the row when missing and belongs to migrations, seeds and consoles.
10
+ def system? = self.class.find_by(name: "system") == self
9
11
  def self.system = find_or_create_by(name: "system")
10
12
 
11
13
  has_many :edges, class_name: 'SuperAuth::ActiveRecord::Edge'
@@ -1,2 +1,14 @@
1
1
  class SuperAuth::Authorization < Sequel::Model(:super_auth_authorizations)
2
+ # Clears and repopulates the compiled table from the current graph, inside
3
+ # one transaction, and returns the row count. Row by row, like
4
+ # SuperAuth::ActiveRecord::Authorization.compile!; a single INSERT ... SELECT
5
+ # is a separate change. Runtime enforcement (ByCurrentUser, the RLS policies)
6
+ # reads only this table, so every edit to the graph is inert until this runs.
7
+ def self.compile!
8
+ db.transaction do
9
+ dataset.delete
10
+ SuperAuth::Edge.authorizations.each { |row| dataset.insert(row) }
11
+ dataset.count
12
+ end
13
+ end
2
14
  end