super_auth 0.3.3 → 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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +286 -22
  6. data/USAGE.md +41 -16
  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/1_users.rb +1 -1
  10. data/db/migrate/5_resources.rb +1 -1
  11. data/db/migrate/7_authorization.rb +2 -2
  12. data/db/migrate/8_add_indexes_to_edges.rb +7 -0
  13. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
  14. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
  16. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  17. data/exe/super_auth-editor +9 -0
  18. data/lib/generators/super_auth/install/templates/README +15 -10
  19. data/lib/generators/super_auth/install/templates/super_auth.rb +16 -5
  20. data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
  21. data/lib/generators/super_auth/rls/templates/migration.rb.erb +15 -0
  22. data/lib/super_auth/active_record/by_current_user.rb +25 -5
  23. data/lib/super_auth/active_record/resource.rb +41 -0
  24. data/lib/super_auth/active_record/user.rb +3 -1
  25. data/lib/super_auth/authorization.rb +12 -0
  26. data/lib/super_auth/edge.rb +65 -100
  27. data/lib/super_auth/editor/cli.rb +91 -0
  28. data/lib/super_auth/editor/index.html +423 -0
  29. data/lib/super_auth/editor/seed.rb +170 -0
  30. data/lib/super_auth/editor.rb +273 -0
  31. data/lib/super_auth/nestable.rb +41 -3
  32. data/lib/super_auth/railtie.rb +0 -2
  33. data/lib/super_auth/rls.rb +256 -0
  34. data/lib/super_auth/user.rb +3 -1
  35. data/lib/super_auth/version.rb +1 -1
  36. data/lib/super_auth.rb +85 -0
  37. data/lib/tasks/super_auth_tasks.rake +28 -0
  38. metadata +15 -9
  39. data/VISUALIZATION.md +0 -58
  40. data/app/controllers/super_auth/graph_controller.rb +0 -654
  41. data/app/views/super_auth/graph/index.html.erb +0 -1408
  42. data/super_auth.gemspec +0 -35
  43. data/visualization.html +0 -747
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1a2db33b2e48f7e542d54f3b5cef127f51c09fe19896b4ec6644a65ffa424c91
4
- data.tar.gz: 87a7b993edea7bab1ca6c3298805f17e62ce0cd8b67de9114167cdb868316d7d
3
+ metadata.gz: ce52cd6a9867c21405fe203e87a765372b5b0febd49109286711c7e546c5118a
4
+ data.tar.gz: 1b67868c607adfa20b6832eb08e7bbf5ecaabe468a92a7ca4a8e408a8f3f2316
5
5
  SHA512:
6
- metadata.gz: 62f14629c43e5dae082d3884803dae6559a91c58118479fc1fc6e4496dec27036c35cf8011e859b16f0e5a30d9c977244d2c39d005936a015796ddd1845b87ee
7
- data.tar.gz: ee2cfaa0d1d3d4b8e3ba56130bc47cdbcb491982fac339bcde956a555b267dc8b39c82da284427f4b7e0baeb6382a5532766135d96922ecd1170079a0333c2f5
6
+ metadata.gz: '0582f16e103f0a1a00e7ac77edc49a3ba35f59cd0d0c98f4a98f1529c6452ca2adad7f1543df9251e923a8c805a0bce04a95c3fe17d8be86b02063b7b578b1a4'
7
+ data.tar.gz: f28dedeb421ee5ea689914429e5dac616f8f3a5efa99272b2e4aea2c715c5c880670936483bcb4cfe8f7e5344cff9c72a917296d6cbdb2a2d92213067a09ac29
data/CHANGELOG.md CHANGED
@@ -1,5 +1,83 @@
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
+
60
+ ## [0.4.0] - 2026-09-02
61
+
62
+ ### Security
63
+
64
+ - Fix: path strategy 1 (users <-> groups <-> roles <-> permissions <-> resources) granted every role held by any group to the members of every group that held a role. `SuperAuth::Edge.users_groups_roles_permissions_resources` built one set of all role-holding groups and one set of all group-held roles and cross-joined them with nothing correlating a group to its own role. The role lookup is now joined through the member's own group ancestry, so a role attached to one group never reaches members of an unrelated group. Affects `SuperAuth::Edge.authorizations` and anything compiled from it (`SuperAuth::ActiveRecord::Authorization.compile!`); recompile authorizations after upgrading.
65
+
66
+ ### Added
67
+
68
+ - Postgres row-level security enforcement (`SuperAuth::RLS`, `rails g super_auth:rls Model ...`). Identity is anchored to the transaction by the `super_auth_become()` SQL function, exposed in Ruby as `SuperAuth.as(user) { ... }`, so non-Ruby clients get the same enforcement.
69
+ - Permission-gated subclass loading: a `ByCurrentUser` subclass is its own resource type, so privileged methods can live on a subclass whose access must be granted explicitly. A grant on the base class does not flow down.
70
+ - `SuperAuth.external_id_type` types the external id columns at install time instead of casting at query time.
71
+
72
+ ### Changed
73
+
74
+ - Relicensed from MIT to GPL-2.0.
75
+ - Path strategies 1, 2 and 3 join group ancestry and role subtrees on integer pairs from two new recursive CTEs (`Group.ancestor_pairs`, `Role.descendant_pairs`) instead of LIKE-matching ids inside the comma-separated path strings, which no planner can index. Output is unchanged. On a 10,000-user graph the full `authorizations` union went from 10.4 s to 2.5 s on Postgres 16; on MySQL 8 a 500-user graph went from 9.9 s to 0.08 s, and on SQLite strategy 1 alone went from over 400 s to 0.01 s.
76
+
77
+ ### Fixed
78
+
79
+ - MySQL 8 support. `SuperAuth::Edge.authorizations` raised "Illegal mix of collations for operation 'UNION'" whenever the connection collation differed from the table collation, which it does under ActiveRecord's defaults, so `compile!` could never run on MySQL. The recursive tree CTEs typed their path columns from the anchor row, so any `group_path` or `role_path` over 11 characters, or name path over 255, failed with "Data too long". Migration 8 no longer adds edge indexes on MySQL, where InnoDB already indexes foreign keys and refuses to drop them, which had broken `uninstall_migrations`. CI now runs the suite against real MySQL instead of silently falling back to SQLite.
80
+
3
81
  ## [0.3.3] - 2026-04-29
4
82
 
5
83
  - Fix: detect PostgreSQL/SQLite/Mysql2 adapter subclasses (e.g. PostGIS, Makara) when bootstrapping the Sequel connection from ActiveRecord. Previously only the exact stock adapter classes were recognized, leaving `SuperAuth.db` unset for apps using a subclassed adapter.
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.3.3)
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
@@ -2,50 +2,241 @@
2
2
 
3
3
  [![Build Status](https://github.com/JonathanFrias/super_auth/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/JonathanFrias/super_auth/actions)
4
4
 
5
- Super auth is turn-key authorization gem that makes unauthorized access unrepresentable. **Stop writing tests for authorization with confidence**
5
+ Super auth is a turn-key authorization engine that makes unauthorized access unrepresentable — enforced in your database, so the same rules protect every client, in any language, that touches your data. **Stop writing authorization tests; enforce access with confidence.**
6
6
 
7
- The intent is to use with ruby applications, as well as centralize authorization for multiple applications. If you look at the [OWASP top vulnerabilty](https://owasp.org/Top10/A01_2021-Broken_Access_Control/), broken
8
- access control is the NUMBER 1 most common security risk in modern applications today. super_auth provides a authentication strategy that allows you to completely de-risk your application, solving this issue once confidently.
7
+ The intent is to centralize authorization for one application or many, in any language. If you look at the [OWASP top vulnerability](https://owasp.org/Top10/A01_2021-Broken_Access_Control/), broken
8
+ access control is the NUMBER 1 most common security risk in modern applications today. super_auth provides an authorization model that lets you de-risk your application, solving this issue once, confidently.
9
9
 
10
10
 
11
11
  ## Installation
12
12
 
13
+ SuperAuth enforces authorization in the database, so any language can participate. The reference client is the Ruby gem:
14
+
13
15
  gem "super_auth"
14
16
 
17
+ ## Supported databases
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
21
+ suite against each of the three. Row-level security is Postgres only.
15
22
 
16
23
  ## Docs
17
24
 
18
25
  How `super_auth` stacks up against other authentication strategies:
19
26
  [Do you really understand Authorization](https://dev.to/jonathanfrias/do-you-really-understand-authorization-1o5d)
20
27
 
21
- ## Graph Visualization
28
+ ## Graph editor
29
+
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.
34
+
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
+
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).
43
+
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.
48
+
49
+ In Rails the engine serves it. Mount the engine inside your own authentication:
22
50
 
23
- SuperAuth includes an interactive graph visualization tool to help you understand and debug your authorization rules!
51
+ ```ruby
52
+ # config/routes.rb
53
+ authenticate :admin do # Devise; or a constraints block
54
+ mount SuperAuth::Engine => "/super_auth"
55
+ end
56
+ ```
24
57
 
25
- ![SuperAuth Visualization](https://img.shields.io/badge/Visualization-Interactive-brightgreen)
58
+ Or mount the app itself anywhere: `require "super_auth/editor"` and
59
+ `mount SuperAuth::Editor => "/wherever"`, again inside your authentication.
26
60
 
27
- See the complete authorization graph with:
28
- - Color-coded nodes (Users, Groups, Roles, Permissions, Resources)
29
- - Interactive path finding
30
- - Real-time authorization queries
31
- - Example scenarios from the README
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
+ ```
72
+
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.
80
+
81
+ ## Postgres Row-Level Security (optional)
82
+
83
+ The `ByCurrentUser` scope enforces authorization at the ORM layer. On Postgres you can
84
+ additionally enforce the same rules inside the database itself, so raw SQL, `unscoped`,
85
+ background jobs, and any other client on the same database are subject to them too —
86
+ unauthorized rows become invisible at the connection level. Enforcement is pure SQL:
87
+ participating apps don't load this gem, or Ruby, at all. The gem's role is
88
+ administrative — define the graph, compile authorizations, enable the policies — which
89
+ is what makes super_auth usable as a central authorization service for apps in any
90
+ language.
91
+
92
+ ### The contract (any language)
93
+
94
+ Identity is asserted per transaction by calling the `super_auth_become` function that
95
+ `SuperAuth::RLS.enable` installs:
96
+
97
+ ```sql
98
+ BEGIN;
99
+ SELECT super_auth_become(user_external_id => '42', user_external_type => 'AppUser');
100
+ -- run normal queries; rows the user isn't authorized for don't exist --
101
+ COMMIT; -- identity dies with the transaction; there is nothing to clear
102
+ ```
32
103
 
33
- **Quick Start:**
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.
122
+
123
+ The assertion is anchored to the calling transaction: `super_auth_become` sets
124
+ transaction-local identity settings plus a stamp of the current transaction id, and
125
+ every policy requires a stamp from the current transaction. Outside a transaction the
126
+ settings have already reverted, and identity smuggled in as session settings carries a
127
+ dead transaction's stamp — either way queries return no rows and writes are rejected.
128
+ Misuse fails closed, and the scheme works unchanged behind transaction-pooling proxies
129
+ like pgbouncer, because a transaction is exactly what they keep on one server
130
+ connection.
131
+
132
+ ### Setup (Rails)
133
+
134
+ **1. Match column types to your primary keys — before your first migration.**
135
+ The policies compare `super_auth_authorizations.resource_external_id` directly
136
+ against your tables' pks with no casting, so the columns must share a type:
137
+
138
+ ```ruby
139
+ # config/initializers/super_auth.rb
140
+ SuperAuth.setup do |config|
141
+ config.external_id_type = :bigint # Rails' default pk type; use :uuid, :string, ... to match yours
142
+ end
143
+ ```
144
+
145
+ If super_auth is already migrated with the wrong type, alter the four external id
146
+ columns (`super_auth_users.external_id`, `super_auth_resources.external_id`,
147
+ `super_auth_authorizations.user_external_id`, `super_auth_authorizations.resource_external_id`)
148
+ in a migration of your own.
149
+
150
+ **2. Enable RLS on the tables you want protected:**
34
151
 
35
152
  ```bash
36
- # 1. Generate initializer
37
- rails generate super_auth:install
153
+ rails generate super_auth:rls Document Invoice
154
+ rails db:migrate
155
+ ```
38
156
 
39
- # 2. Mount the engine in config/routes.rb
40
- mount SuperAuth::Engine => '/super_auth'
157
+ This creates one migration calling `SuperAuth::RLS.enable(:documents, resource_type: "Document")`
158
+ per model you can also call that directly for tables outside Rails. `resource_type`
159
+ must match the `resource_external_type` used in your authorization rows (the model's
160
+ class name when you use the AR integration).
161
+
162
+ **3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
163
+ policies entirely, so the app must not connect as one (owning the tables is fine —
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:
169
+
170
+ ```sql
171
+ CREATE ROLE app_runtime LOGIN PASSWORD '...';
172
+ GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
173
+ ```
41
174
 
42
- # 3. Load sample data (optional)
43
- rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
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
44
180
  ```
45
181
 
46
- Then visit: `http://localhost:3000/super_auth/visualization`
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
+
186
+ > ⚠️ **This is the one step that, if skipped, silently disables all protection.**
187
+ > PostgreSQL *always* lets **superusers** and roles with the **`BYPASSRLS`** attribute
188
+ > bypass row-level security. `FORCE ROW LEVEL SECURITY` only subjects the table *owner*
189
+ > to the policies — it does **not** constrain a superuser. So if your app connects to
190
+ > Postgres as a superuser (the default in many local setups and some managed hosts), the
191
+ > policies apply to nobody and every row stays visible, while everything *looks* like it
192
+ > is working. Always connect as a dedicated non-superuser, non-`BYPASSRLS` role such as
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.
47
196
 
48
- See [VISUALIZATION.md](VISUALIZATION.md) for complete documentation.
197
+ **4. Wrap work in an identity assertion.** In Ruby:
198
+
199
+ ```ruby
200
+ SuperAuth.as(current_user) do
201
+ # every query in here is enforced by the database
202
+ end
203
+ ```
204
+
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
218
+ semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
219
+ act as a wildcard, per-record authorizations match on id. Any object with an `id`
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`.
223
+
224
+ ### Notes
225
+
226
+ - Queries with no identity asserted see nothing, and writes are rejected — fail
227
+ closed, by design. A client that has never heard of super_auth cannot accidentally
228
+ reach protected rows.
229
+ - Creating rows requires a type-level authorization for that resource type (or system
230
+ context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
231
+ `USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs.
232
+ - The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
233
+ id even to read-only transactions — one extra xid per protected transaction.
234
+ Negligible for almost everyone; revisit with a virtual-xid variant only if
235
+ transaction id churn ever matters at extreme read volume.
236
+ - One `external_id_type` covers the whole install, so every protected table across
237
+ every participating app needs the same pk type.
238
+ - Postgres 13+ only (`pg_current_xact_id`). On other databases `SuperAuth::RLS`
239
+ raises, and the ORM scope remains the enforcement layer.
49
240
 
50
241
  ## Configuration
51
242
 
@@ -55,12 +246,18 @@ SuperAuth.setup do |config|
55
246
  # Raise an error when a query runs without a current user set.
56
247
  # Default is :none (returns empty results silently).
57
248
  config.missing_user_behavior = :raise
249
+
250
+ # Column type for external id columns, applied when the migrations run. Set
251
+ # it to your application's primary key type (:bigint, :uuid, :string, ...) so
252
+ # authorization comparisons are natively typed. Default is :string.
253
+ config.external_id_type = :bigint
58
254
  end
59
255
  ```
60
256
 
61
257
  | Option | Values | Default | Description |
62
258
  |--------|--------|---------|-------------|
63
259
  | `missing_user_behavior` | `:none`, `:raise` | `:none` | Controls what happens when `SuperAuth.current_user` is blank. `:none` returns an empty result set. `:raise` raises `SuperAuth::Error`. |
260
+ | `external_id_type` | `:string`, `:bigint`, `:uuid`, ... | `:string` | Column type for the external id columns, applied when the migrations run. Set it to your application's primary key type so every comparison against your tables' pks is natively typed — no casting anywhere. |
64
261
 
65
262
  ## Usage
66
263
 
@@ -75,6 +272,9 @@ SuperAuth is a rules engine engine that works on 5 different authorization conce
75
272
  The basis for how this works is that the rules engine is trying to match a user with a resource to determine access.
76
273
  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.
77
274
 
275
+ +---+ +---+
276
+ | | | | (Group nests within Group,
277
+ | v | v Role nests within Role)
78
278
  +-------+ +------+
79
279
  | Group |<----->| Role |
80
280
  +-------+\ / +------+
@@ -92,7 +292,9 @@ The engine determines if it can find an authorization route betewen a user and a
92
292
 
93
293
 
94
294
  The lines between the boxes are called [edges](https://en.wikipedia.org/wiki/Glossary_of_graph_theory#edge).
95
- Note that `Group` and `Role` trees.
295
+ The self-loops on `Group` and `Role` mean each nests within itself: a `Group` can contain
296
+ child `Group`s and a `Role` can contain child `Role`s, recursively. Grants on a parent
297
+ flow to every descendant — which is why `Group` and `Role` are described as *trees*.
96
298
 
97
299
  In general the super_auth has 5 different pathing strategies to search for access.
98
300
 
@@ -210,6 +412,68 @@ Since the path is stored with the record, it trivial to audit access permissions
210
412
 
211
413
  TODO: Write usage instructions here
212
414
 
415
+ ## Permission-Gated Models
416
+
417
+ Every class is authorized by its own name — nothing is derived, and a grant on one class never flows to another. That makes a subclass the natural home for privileged methods: it shares the base class's table and rows, but loading it requires its own, explicitly approved grant. If you can't load the object, you can't call its methods.
418
+
419
+ ```ruby
420
+ class Resource < ApplicationRecord
421
+ super_auth
422
+ # Loadable by users granted the "Resource" resource type.
423
+
424
+ class ResourceRestartPermission < Resource
425
+ # Loadable ONLY by users granted "Resource::ResourceRestartPermission".
426
+ def restart!
427
+ # dangerous restart operation
428
+ end
429
+ end
430
+ end
431
+ ```
432
+
433
+ Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it:
434
+
435
+ ```ruby
436
+ restartable = SuperAuth::Resource.create(
437
+ name: "restartable servers",
438
+ external_type: "Resource::ResourceRestartPermission"
439
+ )
440
+ restart = SuperAuth::Permission.create(name: "restart")
441
+ SuperAuth::Edge.create(user: sa_user, permission: restart)
442
+ SuperAuth::Edge.create(permission: restart, resource: restartable)
443
+ SuperAuth::ActiveRecord::Authorization.compile!
444
+
445
+ Resource.find(id) # needs a "Resource" grant
446
+ Resource::ResourceRestartPermission.find(id) # needs its own explicit approval
447
+ ```
448
+
449
+ Grants are per class in both directions: a `"Resource"` grant does not unlock the subclass, and a `"Resource::ResourceRestartPermission"` grant does not unlock the base class.
450
+
451
+ ## Row-Level Security for permission-gated models
452
+
453
+ 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):
454
+
455
+ ```ruby
456
+ SuperAuth::RLS.enable(:resources, resource_type: "Resource")
457
+ ```
458
+
459
+ `enable` turns on `ROW LEVEL SECURITY` (with `FORCE`, so the table owner is covered too) and installs a policy that derives visibility from `super_auth_authorizations`. Identity is asserted **per transaction, not per connection**: wrap the work in `SuperAuth.as`, which opens a transaction and calls `super_auth_become` for you (see the contract above). Every query inside is filtered, and the identity dies with the transaction:
460
+
461
+ ```ruby
462
+ SuperAuth.as(current_user) do
463
+ SuperAuth.db[:resources].all # only rows current_user holds a grant on
464
+ end
465
+ # outside the block there is no asserted identity, so the policy matches nothing
466
+ ```
467
+
468
+ Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level wildcard grants (`resource_external_id IS NULL`) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
469
+
470
+ Because a policy sees only the table, not which Ruby class issued the query, row-level security enforces access to the **base** resource type: a `"Resource"` grant makes the row visible in the database, but the policy cannot distinguish the `"Resource::ResourceRestartPermission"` subclass. Per-class (capability) enforcement therefore stays with the ORM scope — the database is the row-visibility backstop, the client gates the capability.
471
+
472
+ Notes:
473
+
474
+ - With no `SuperAuth.as` assertion in effect, the policy matches nothing (deny by default) and writes are rejected — fail closed.
475
+ - Postgres superusers and `BYPASSRLS` roles bypass row-level security entirely — run your application as a regular role (see the setup guide above).
476
+
213
477
  ## Development
214
478
 
215
479
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -222,4 +486,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/Jonath
222
486
 
223
487
  ## License
224
488
 
225
- The gem is available as open source under the terms of the [GPL](https://www.gnu.org/licenses/quick-guide-gplv3.html).
489
+ The gem is available as open source under the terms of the [GPL v2](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html).
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
 
@@ -436,6 +440,29 @@ SuperAuth.current_user = SuperAuth::User.system
436
440
  Post.all # Returns all posts
437
441
  ```
438
442
 
443
+ ### Permission-gated subclasses
444
+
445
+ Every class is authorized by its own name, so privileged methods belong on a subclass: it shares the base class's table and rows, but loading it requires its own explicitly approved grant — access never flows between base and subclass in either direction:
446
+
447
+ ```ruby
448
+ class Post < ApplicationRecord
449
+ super_auth
450
+
451
+ class PostPublishPermission < Post
452
+ def publish!
453
+ update!(published: true)
454
+ end
455
+ end
456
+ end
457
+
458
+ Post.find(id) # needs a "Post" grant
459
+ Post::PostPublishPermission.find(id) # needs a "Post::PostPublishPermission" grant
460
+ ```
461
+
462
+ Approve the subclass like any other resource — register a `SuperAuth::Resource` with `external_type: "Post::PostPublishPermission"` and draw edges to it, then recompile with `SuperAuth::ActiveRecord::Authorization.compile!`.
463
+
464
+ For database-side enforcement of the same rules see "Postgres Row-Level Security" in the README (`SuperAuth::RLS.enable` — visibility is derived automatically from `SuperAuth.current_user`).
465
+
439
466
  ### Linking to your app's models
440
467
 
441
468
  Connect SuperAuth entities to your ActiveRecord models via `external_id` and `external_type`:
@@ -510,25 +537,23 @@ deployers.map { |a| a[:user_name] }.uniq
510
537
 
511
538
  ## Visualization
512
539
 
513
- 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.
514
544
 
515
- ```ruby
516
- # config/routes.rb
517
- mount SuperAuth::Engine => '/super_auth'
518
- ```
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.
519
550
 
520
- Load sample data (optional):
551
+ Load sample data (optional, Rails):
521
552
 
522
553
  ```bash
523
554
  rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
524
555
  ```
525
556
 
526
- Visit `http://localhost:3000/super_auth/visualization` to see:
527
-
528
- - Color-coded nodes for each entity type
529
- - Interactive path finding
530
- - Real-time authorization queries
531
-
532
557
  ## Full Example
533
558
 
534
559
  Here's a complete example modeling a company with departments, roles, and resources:
@@ -616,4 +641,4 @@ auths = SuperAuth::Edge.authorizations.all
616
641
 
617
642
  ## License
618
643
 
619
- SuperAuth is available as open source under the [GPL License](https://www.gnu.org/licenses/quick-guide-gplv3.html).
644
+ SuperAuth is available as open source under the [GPL v2 License](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html).