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 +4 -4
- data/CHANGELOG.md +57 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +97 -31
- data/USAGE.md +17 -15
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
- data/exe/super_auth-editor +9 -0
- data/lib/generators/super_auth/install/templates/README +15 -10
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/by_current_user.rb +1 -1
- data/lib/super_auth/active_record/resource.rb +41 -0
- data/lib/super_auth/active_record/user.rb +3 -1
- data/lib/super_auth/authorization.rb +12 -0
- data/lib/super_auth/editor/cli.rb +91 -0
- data/lib/super_auth/editor/index.html +423 -0
- data/lib/super_auth/editor/seed.rb +170 -0
- data/lib/super_auth/editor.rb +273 -0
- data/lib/super_auth/railtie.rb +0 -2
- data/lib/super_auth/rls.rb +164 -34
- data/lib/super_auth/user.rb +3 -1
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +42 -5
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +11 -8
- data/VISUALIZATION.md +0 -58
- data/app/controllers/super_auth/graph_controller.rb +0 -654
- data/app/views/super_auth/graph/index.html.erb +0 -1408
- data/super_auth.gemspec +0 -35
- data/visualization.html +0 -747
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce52cd6a9867c21405fe203e87a765372b5b0febd49109286711c7e546c5118a
|
|
4
|
+
data.tar.gz: 1b67868c607adfa20b6832eb08e7bbf5ecaabe468a92a7ca4a8e408a8f3f2316
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+
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
|
|
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
34
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
mount SuperAuth::Engine => '/super_auth'
|
|
49
|
+
In Rails the engine serves it. Mount the engine inside your own authentication:
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
81
|
-
|
|
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`)
|
|
125
|
-
`
|
|
126
|
-
`
|
|
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.
|
|
142
|
-
>
|
|
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`
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
103
|
+
authenticate :admin do # Devise; or a constraints block
|
|
104
|
+
mount SuperAuth::Engine => '/super_auth'
|
|
105
|
+
end
|
|
103
106
|
```
|
|
104
107
|
|
|
105
|
-
Then
|
|
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
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
|
@@ -6,24 +6,29 @@ Next steps:
|
|
|
6
6
|
|
|
7
7
|
1. Run migrations to create the database tables:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
rails db:migrate
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
16
|
+
authenticate :admin do
|
|
17
|
+
mount SuperAuth::Engine => '/super_auth'
|
|
18
|
+
end
|
|
16
19
|
|
|
17
|
-
3. (Optional) Load sample data to
|
|
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
|
|
24
|
+
4. Start your Rails server and open the editor:
|
|
22
25
|
|
|
23
|
-
http://localhost:3000/super_auth
|
|
26
|
+
http://localhost:3000/super_auth
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
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
|
-
|
|
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
|