super_auth 0.4.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +90 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +132 -49
- data/USAGE.md +98 -31
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/seeds/sample_data.rb +1 -0
- data/exe/super_auth-editor +9 -0
- data/lib/generators/super_auth/install/templates/README +18 -11
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +7 -0
- data/lib/super_auth/active_record/by_current_user.rb +1 -1
- data/lib/super_auth/active_record/resource.rb +45 -0
- data/lib/super_auth/active_record/user.rb +3 -1
- data/lib/super_auth/authorization.rb +24 -0
- data/lib/super_auth/edge.rb +54 -18
- data/lib/super_auth/editor/cli.rb +91 -0
- data/lib/super_auth/editor/index.html +430 -0
- data/lib/super_auth/editor/seed.rb +176 -0
- data/lib/super_auth/editor.rb +288 -0
- data/lib/super_auth/nestable.rb +16 -3
- data/lib/super_auth/railtie.rb +9 -2
- data/lib/super_auth/resource.rb +57 -0
- 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 +73 -5
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +13 -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: 0f41f8bbae64f5e5ddf3666026d00a64cbe4007037b2c859152b7b3c2e331023
|
|
4
|
+
data.tar.gz: 6e8b002d7a9e23787565e85494c23b1a1f912770ad800b7a1986a23188701d6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bde433eba068b2b55edad37701dac014340a5e840e26322238760d554f1e44e8aabc31d934ed776207f02439563ffb52083195cbf99207807ac8e0b23dbf7b5a
|
|
7
|
+
data.tar.gz: eb3456e6855c070931b5488fd8632eb60d74305753ea007d346f47a11f257aec2104d83061dafdea09f43ab7e90cddfaf4aaf69be236010f2f0896a3821242b0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,95 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `super_auth_resources.parent_id`, a nullable self-referencing key, so resource nodes nest the way groups and roles do. `SuperAuth::Resource` includes `SuperAuth::Nestable` and gains the same tree API: `parent`, `children`, `ancestors`, `descendants` (and the `_dataset` variants), `roots`, `trees`, `ancestor_pairs` and `descendant_pairs`. A node with neither `external_type` nor `external_id` is a container: one per folder, project, tenant or whatever the application nests records under, with the records registered beneath it. Migration 11 in both `db/migrate/` and `db/migrate_activerecord/`.
|
|
8
|
+
- A grant on a resource node reaches the node and every node under it, through all five path strategies. The last hop of each strategy is a join through `SuperAuth::Resource.descendant_pairs` where it was a primary-key join, so the subtree is expanded at compile time and `super_auth_authorizations` gains no columns: `ByCurrentUser` and the row-level security policy are untouched, still read only `resource_external_type` and `resource_external_id` from the compiled table, and cannot tell a row compiled through a container from one granted directly. No recompile is needed after upgrading. On a flat graph the pairs relation is the identity and the compiled rows are exactly what 0.7.0 produced.
|
|
9
|
+
- The pairs CTE is anchored on the resource ids that appear in `super_auth_edges.resource_id` (`descendant_pairs(of:)`, wrapped by `SuperAuth::Edge.resource_subtrees`), so the walk is sized by the grants rather than by the table. Groups and roles are few and the whole table is cheap; resources are one row per protected record, and an unanchored CTE materialises every pair of the whole table once per strategy that joins it. Measured at 300,000 resources: MySQL 8 went from 27 s per strategy unanchored to 0.025 s anchored, Postgres from 0.3–0.6 s to 0.008 s.
|
|
10
|
+
- No `resource_path` or `resource_name_path` columns, a deliberate asymmetry with groups and roles. A row compiled through a container carries the descendant node's own `resource_id`, `resource_name`, `resource_external_type` and `resource_external_id` — the record the grant reaches, which is all runtime reads. "Granted through which container" is answered by the graph (`parent_id`, `parent`, `children`) and by the editor, not by the compiled table; the trade is that an audit of the compiled table alone no longer sees the container an edge was drawn to.
|
|
11
|
+
- Containment is not inheritance. Because the compiled row copies the descendant's own `external_type`, the permission-gated subclass rule holds — a `"Post"` grant still says nothing about `"Post::PostPublishPermission"` — but a capability node registered *under* its base-class node is a descendant of it and receives every grant on the base. Register capability nodes as siblings of their base-class nodes, or in a container beside them, never as their children. A documentation rule, not a check: the gem cannot tell a capability subclass from any other type name.
|
|
12
|
+
- The editor renders resources as a tree, creates containers (a resource node with no external link, at the root or under a chosen parent), and refuses two things with a 422 and the reason: creating a node under a wildcard parent, and a compile refused by the guard below (in 0.7.0 a resource create with any `parent_id` was refused as "resource records cannot have a parent"; that message no longer occurs for resources). Application code registers records under a container by saving a node with `parent_id`; there is no re-parent route yet.
|
|
13
|
+
- `SuperAuth.deprecator`, where the gem's deprecation warnings go. An `ActiveSupport::Deprecation` when ActiveSupport is loaded, which the railtie registers as `app.deprecators[:super_auth]` so `config.active_support.deprecation` and `report_deprecations` apply on Rails 7.1+; otherwise a stand-in with the same `warn` and `silenced=`.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- The editor's second Users box. The bottom row drew Resources beside a duplicate of the Users box from the top row — same rows, shared selection — with nothing saying why; Resources now spans the bottom row and users are listed once.
|
|
18
|
+
|
|
19
|
+
### Deprecated
|
|
20
|
+
|
|
21
|
+
- Type-level (wildcard) resource nodes: a node with an `external_type` and no `external_id`, which at runtime means every record of that type, present and future (`ByCurrentUser`'s type-level branch; the policy's `resource_external_id IS NULL OR` clause). They keep working, unchanged, and no removal version is promised. What changes is at compile time. `compile!` (both twins) now warns once per compile through `SuperAuth.deprecator`, naming up to ten of them, and refuses a wildcard that has a parent or children, raising `SuperAuth::Error` ("Wildcard resource nodes must be flat, but wildcard node(s) 12, 15 have a parent or children. …") before the compiled table is touched, so the previous rows stay. That is the one shape the tree cannot hold: nested under a container, a wildcard would compile to a `(type, NULL)` row reachable through every ancestor's grants, one edge to a container silently granting every record of a type; and nodes under a wildcard are unreachable except through a grant that already covers them. The rule is that wildcard nodes are flat: at the root, with no children, or given an `external_id`. The guard and the compile are two statements, so the subtree join carries the same rule as a predicate: a `(type, NULL)` row is only ever the node a grant named, never one reached through a parent, whatever a concurrent write commits between the two under `READ COMMITTED`. The guard is the loud error; the predicate is the guarantee.
|
|
22
|
+
- A container is not a replacement for a wildcard on a table under row-level security, and the wildcard remains the only way to authorize INSERT there this release. The policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses `USING` for new rows, and a per-record row can only match an id the application has already registered and compiled. Moving an RLS-protected type from a wildcard to a container loses INSERT, needs a resource node saved and a full recompile for every new record before it is visible, and needs a role that can write the gem's tables, which `enable` grants `SELECT` on only. The successor is a grant on a parent record — `SuperAuth::RLS.enable(:documents, resource_type: "Document", parent: { column: :folder_id, resource_type: "Folder" })`, with the policy matching per record on `t.id` or per record on the parent column, and a `ByCurrentUser` mirror — planned for the next release, which is when the wildcard stops being needed. Until then a wildcard on an RLS host is the supported shape and the warning is a notice, not a fault.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- `compile!` turns Postgres JIT off for its own transaction (`SET LOCAL jit = off`). Postgres was JIT-compiling the five-strategy union's expressions on every compile — 358 LLVM functions in 0.7.0, 539 with the new subtree joins — spending 1.6 s (0.7.0) to 2.2 s (0.8.0) in LLVM optimisation and emission for a query that executes in about 10 ms; the whole of the "compile is slow on Postgres" cost was JIT, on any graph size. `SET LOCAL` dies with the transaction, so nothing reaches the pooled connection. `SuperAuth::Edge.authorizations` called on its own is unchanged.
|
|
27
|
+
- `compile!` no longer runs forever on a `parent_id` cycle. The two pair CTEs in `SuperAuth::Nestable` (`ancestor_pairs`, `descendant_pairs`) recurse with `UNION` instead of `UNION ALL`: the pair relation is finite, so `UNION` stops the first time a step produces nothing new, which on a cycle is the first time round, where `UNION ALL` re-derived the same pairs without end. MySQL aborted after 1001 iterations (`cte_max_recursion_depth`); Postgres and SQLite looped until killed. Output is unchanged on a valid tree, where no step repeats a pair. The path-building tree CTEs the strategies join for group and role path columns are anchored on the roots and walk downward, so they never enter a cycle: a cyclic group or role component is unreachable from any root and every grant that passes through it compiles to no rows, silently, fail-closed. A check against cycles at write time remains open.
|
|
28
|
+
|
|
29
|
+
### Upgrade notes
|
|
30
|
+
|
|
31
|
+
- Run migration 11 before deploying code that saves resource nodes or compiles: `SuperAuth::Resource` now reads `parent_id`, so `compile!` on an un-migrated schema fails with a missing-column error. In Rails, `rails railties:install:migrations` (or the app's equivalent) copies the new migration into the app and `rails db:migrate` runs it — the engine points the install task at `db/migrate_activerecord` but does not run its migrations on its own, which the install generator's notes used to claim and now do not. Without Rails, `SuperAuth.install_migrations` or `super_auth-editor --migrate`; `rake super_auth:migrate` exists only inside a Rails app (its tasks depend on `:environment`).
|
|
32
|
+
- No recompile: the compiled table has the same columns and, on a flat graph, the same rows.
|
|
33
|
+
- With wildcard nodes in the graph, every compile prints one deprecation line naming them. Silence it with the Rails deprecation config — the deprecator is `app.deprecators[:super_auth]`, so `config.active_support.deprecation = :silence` and `config.active_support.report_deprecations = false` both apply — or with `SuperAuth.deprecator.silenced = true`.
|
|
34
|
+
- Compiled-table growth: a grant on a container compiles one row per descendant per path, and `compile!` inserts row by row, so a container over many records lengthens every compile in proportion. A single `INSERT ... SELECT` is the tracked follow-up.
|
|
35
|
+
|
|
36
|
+
## [0.7.0] - 2026-09-07
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- The editor's Groups and Roles boxes drew a flat list ordered by name and faked the hierarchy with indentation, so a child appeared nested under whichever unrelated node happened to sort directly above it — a group named `org:451ed5a8…` sorts between "Lawyers" and "Organizations" and was drawn under Lawyers, while its real parent rendered below its own child. Group grants flow to members of descendant groups, so "which parent does this hang under" is the question the editor is opened to answer, and it was answering it wrong. `/api/graph` now returns nested types ordered by ancestor name path, so each child follows its own parent with siblings alphabetical among themselves. Display only: no `parent_id` changes, and the client's `depthOf` is untouched. The order stays total for a broken tree — a dangling `parent_id` sorts as a root, and a parent cycle terminates rather than hanging the sort.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `super_auth_resources.super_auth_label`, a nullable column holding the human name of the application record a resource node points at, so the editor can render "Gulf War presumptive" where it rendered `Claim#3a00b6fa-2998-41ba-953f-a3b0de1876b3`. The label is stored rather than resolved at render time for three reasons: the editor reads bare Sequel models with no association to the application, `super_auth-editor` serves the same UI against a bare `SUPER_AUTH_DATABASE_URL` with no application loaded at all, and RLS makes exactly the largest protected resource types unreadable without an asserted identity — a live lookup would return empty labels for those and full labels for everything else, which reads as data rather than as a missing permission. The column carries the prefix for the same reason the opt-in method does: `label` is a name applications want for themselves. Migration 10 in both `db/migrate/` and `db/migrate_activerecord/`.
|
|
45
|
+
- `SuperAuth.label_for(record)` derives that name by convention rather than configuration: `super_auth_label` if the model defines it, then `name`, then `title`. Never `to_s` — the label sits where `Type#id` otherwise renders, and `#<Claim:0x000055…>` is worse than the id it would replace.
|
|
46
|
+
- `SuperAuth::ActiveRecord::Resource` derives its label on save, so a host that already syncs resource nodes gets labels with no extra wiring, and `#refresh_label!` re-derives it after the application record is renamed, which does not write the node. A nil derivation never overwrites a stored label: RLS blanking the record, an `external_type` naming a class this process has not loaded, and a type-level row (`external_id IS NULL`) all derive nil, and none of them means the record has no name.
|
|
47
|
+
- The editor renders the label in the slot that held `Type#id`, demoting `Type#id` to the tooltip, and falls back to today's rendering for a node without one. Its per-box filter now matches everything a row carries — name, label, `external_type` and `external_id` — so a pasted uuid finds its node, which it never did before: the filter only ever looked at `name`.
|
|
48
|
+
- `rake super_auth:labels:backfill` labels existing rows and repairs drift. It asserts the system identity where RLS is installed, because the application tables it reads are the ones RLS protects — run without an identity it would derive nil for the rows that matter most and report success.
|
|
49
|
+
|
|
50
|
+
A stored label is a snapshot. It drifts between the application record being renamed and the next `refresh_label!` or backfill, and that is the trade: a stale or missing label degrades to the `Type#id` the editor rendered before, never to something wrong. `super_auth_users.name` has always been a denormalized label of the same kind and never refreshes at all, so this is the existing idea finished rather than a new one.
|
|
51
|
+
|
|
52
|
+
Users are out of scope: `super_auth_users.name` already carries a human name, so user nodes already read as "Jonathan Frias" in the editor. Groups, roles and permissions encode their application link in host-specific name conventions and are a separate job.
|
|
53
|
+
|
|
54
|
+
## [0.6.0] - 2026-09-07
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- `SuperAuth::RLS.assert(user)` asserts the database identity in the transaction the caller already holds and opens nothing: the `SELECT super_auth_become(...)` half of the SQL contract, or `super_auth_system()` for a system user. For code that changes user mid-transaction or manages its own transaction; `SuperAuth.as` is now this plus a transaction plus restore.
|
|
59
|
+
- `SuperAuth::RLS.installed?` reports whether `enable` has run on the database, so an application that degrades when RLS is absent no longer probes for the SQL functions by signature. A probe written against one signature returns false when the signature changes and turns row-level security off with a green suite, which is what a 0.4.0 probe does against 0.5.0.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- `SuperAuth.as` assigns `SuperAuth.current_user` inside the transaction, after the database identity is asserted, so an application that hooks the writer to re-assert does so on the transaction's connection instead of once outside it and once in.
|
|
64
|
+
|
|
65
|
+
### Removed
|
|
66
|
+
|
|
67
|
+
- `on_error:` on `SuperAuth.as` and `SuperAuth::RLS.as`, added in 0.5.0. Whether a write survives the block raising is the caller's decision, not the identity wrapper's: rescue inside the block to keep it, let the exception out to roll it back. `as` has one control-flow path again.
|
|
68
|
+
|
|
69
|
+
## [0.5.0] - 2026-09-07
|
|
70
|
+
|
|
71
|
+
### Security
|
|
72
|
+
|
|
73
|
+
- Row-level security: the system bypass is no longer a parameter of `super_auth_become()`. It moved to a separate function, `super_auth_system()`, whose `EXECUTE` privilege `enable` revokes from `PUBLIC`, so the right to bypass every policy is granted per role (`GRANT EXECUTE ON FUNCTION super_auth_system() TO <role>`) instead of coming with the right to assert an identity. `SuperAuth.as(user)` calls it for users whose `system?` is true. Both functions now raise if the calling role is a superuser or has `BYPASSRLS`, because Postgres exempts those roles from row security and the assertion would protect nothing. Breaking for clients that call the SQL directly: `super_auth_become(...)` takes three arguments and the four-argument overload is dropped on the next `enable`; roles that bypass need the grant above.
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- A graph editor, Rails-free, shipped as a mountable Rack app (`SuperAuth::Editor`, `require "super_auth/editor"`) and a command (`super_auth-editor`) that serves it on loopback against `SUPER_AUTH_DATABASE_URL`, with `--migrate` and `--seed` as explicit, opt-in steps. Five boxes with client-side traversal, node and edge CRUD, and a Recompile button. It has no authentication of its own: mount it inside yours. Writes must be JSON, only the eight edge kinds the path strategies read can be created, and the command rejects foreign `Host` headers.
|
|
78
|
+
- `SuperAuth::Authorization.compile!` for applications without ActiveRecord, and `POST /api/compile` in the editor. Runtime enforcement reads only the compiled table, so every graph edit is inert until it runs.
|
|
79
|
+
|
|
80
|
+
### Fixed
|
|
81
|
+
|
|
82
|
+
- `ByCurrentUser` now recognises a Sequel `SuperAuth::User` as an internal user and matches it on `user_id`, as the RLS policies already did; before, it was treated as an external object and silently saw nothing. Both layers share `SuperAuth.internal_user?`.
|
|
83
|
+
|
|
84
|
+
### Removed
|
|
85
|
+
|
|
86
|
+
- The d3 graph visualizer: `SuperAuth::GraphController`, its view, its JSON API (`/graph/data`, `/graph/authorize`, `/graph/orphaned`, `/graph/compile_authorizations`, and the `/graph/*` create and delete routes), `visualization.html`, and `VISUALIZATION.md`. `mount SuperAuth::Engine => "/super_auth"` now serves the graph editor at that path; it has no authentication of its own, so mount it inside yours. Anything that called the old JSON routes must move to the editor's API or to the models.
|
|
87
|
+
|
|
88
|
+
### Changed
|
|
89
|
+
|
|
90
|
+
- `SuperAuth.as(user)` now carries both identities: it sets `SuperAuth.current_user` (read by the `ByCurrentUser` scope) for the block as well as asserting the database identity (read by the RLS policies), and restores both on the way out, on return, on raise, and when nested. `SuperAuth::RLS.as` stays the pure SQL-contract wrapper and now restores the enclosing database identity when it is nested inside a transaction, so an inner block can no longer leave the outer one running as its user. Inside a transaction the caller opened, `as` joins it. One option replaces the wrapper an application used to need: `auto_savepoint: true` makes every nested transaction a savepoint (the ActiveRecord bridge turns it into `joinable: false`), so a save inside the block commits on its own and its `after_commit` hooks fire then. Other keyword options pass through to Sequel's `transaction`. Behaviour change: `SuperAuth.as(nil)` runs the block with `current_user = nil`, so apps with `missing_user_behavior = :raise` now raise on scoped queries inside an anonymous block instead of inheriting whatever the thread-local held before. Clients that probe for the identity function must look for `super_auth_become(text, text, text)`; the four-argument signature is gone (see Security above).
|
|
91
|
+
- `SuperAuth::RLS.enable` grants `SELECT` on `super_auth_authorizations` and `super_auth_users` to `PUBLIC`, so a runtime role needs privileges on the application's tables and nothing else; `SuperAuth::RLS.grant_system(role)` hands out the bypass without hand-written SQL. `system?` on both user models is now a read-only lookup (`.system` still creates the row), so passing SuperAuth user records to `SuperAuth.as` never needs `INSERT`.
|
|
92
|
+
|
|
3
93
|
## [0.4.0] - 2026-09-02
|
|
4
94
|
|
|
5
95
|
### Security
|
data/Gemfile
CHANGED
|
@@ -15,4 +15,9 @@ group :development, :test do
|
|
|
15
15
|
gem "activerecord"
|
|
16
16
|
gem "sequel-activerecord_connection"
|
|
17
17
|
gem "after_commit_everywhere"
|
|
18
|
+
# Rack::MockRequest and Rack::Lint for the editor specs, and a server for
|
|
19
|
+
# exe/super_auth-editor. The gem itself depends on none of them.
|
|
20
|
+
gem "rack"
|
|
21
|
+
gem "rackup"
|
|
22
|
+
gem "webrick"
|
|
18
23
|
end
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
super_auth (0.
|
|
4
|
+
super_auth (0.8.0)
|
|
5
5
|
sequel
|
|
6
6
|
|
|
7
7
|
GEM
|
|
@@ -54,6 +54,9 @@ GEM
|
|
|
54
54
|
coderay (~> 1.1)
|
|
55
55
|
method_source (~> 1.0)
|
|
56
56
|
reline (>= 0.6.0)
|
|
57
|
+
rack (3.2.7)
|
|
58
|
+
rackup (2.3.1)
|
|
59
|
+
rack (>= 3)
|
|
57
60
|
rake (13.3.1)
|
|
58
61
|
reline (0.6.3)
|
|
59
62
|
io-console (~> 0.5)
|
|
@@ -82,6 +85,7 @@ GEM
|
|
|
82
85
|
tzinfo (2.0.6)
|
|
83
86
|
concurrent-ruby (~> 1.0)
|
|
84
87
|
uri (1.1.1)
|
|
88
|
+
webrick (1.9.2)
|
|
85
89
|
zeitwerk (2.7.5)
|
|
86
90
|
|
|
87
91
|
PLATFORMS
|
|
@@ -96,12 +100,15 @@ DEPENDENCIES
|
|
|
96
100
|
mysql2
|
|
97
101
|
pg
|
|
98
102
|
pry
|
|
103
|
+
rack
|
|
104
|
+
rackup
|
|
99
105
|
rake (~> 13.0)
|
|
100
106
|
rspec (~> 3.0)
|
|
101
107
|
sequel
|
|
102
108
|
sequel-activerecord_connection
|
|
103
109
|
sqlite3
|
|
104
110
|
super_auth!
|
|
111
|
+
webrick
|
|
105
112
|
zeitwerk (~> 2.6)
|
|
106
113
|
|
|
107
114
|
BUNDLED WITH
|
data/README.md
CHANGED
|
@@ -16,8 +16,8 @@ SuperAuth enforces authorization in the database, so any language can participat
|
|
|
16
16
|
|
|
17
17
|
## Supported databases
|
|
18
18
|
|
|
19
|
-
PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group and
|
|
20
|
-
CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
|
|
19
|
+
PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group, role and resource trees are
|
|
20
|
+
recursive CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
|
|
21
21
|
suite against each of the three. Row-level security is Postgres only.
|
|
22
22
|
|
|
23
23
|
## Docs
|
|
@@ -25,34 +25,62 @@ suite against each of the three. Row-level security is Postgres only.
|
|
|
25
25
|
How `super_auth` stacks up against other authentication strategies:
|
|
26
26
|
[Do you really understand Authorization](https://dev.to/jonathanfrias/do-you-really-understand-authorization-1o5d)
|
|
27
27
|
|
|
28
|
-
## Graph
|
|
28
|
+
## Graph editor
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
A Rails-free editor for the authorization graph: five boxes (groups, roles, users,
|
|
31
|
+
permissions, resources), of which groups, roles and resources are drawn as trees; click
|
|
32
|
+
any record to trace what it can reach and what reaches it; connect two records to draw
|
|
33
|
+
an edge; create records, including a resource container under a chosen parent; delete
|
|
34
|
+
records and edges; recompile. The editor makes containers, not application records:
|
|
35
|
+
your code registers a record under a container by saving a resource node with
|
|
36
|
+
`parent_id`, and a grant on the container reaches everything under it. It ships in the
|
|
37
|
+
gem as a Rack app and a command.
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
- Interactive path finding
|
|
37
|
-
- Real-time authorization queries
|
|
38
|
-
- Example scenarios from the README
|
|
39
|
+
```bash
|
|
40
|
+
gem install super_auth rackup webrick # any Rack server works; puma too
|
|
41
|
+
SUPER_AUTH_DATABASE_URL=postgres://user:password@localhost/app_development super_auth-editor
|
|
42
|
+
```
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
Then open http://127.0.0.1:4666. Options: `--host` (default `127.0.0.1`), `--port`
|
|
45
|
+
(default `4666`), `--migrate` (run the gem's Sequel migrations first, for a database that
|
|
46
|
+
has none), `--seed` (replace the whole graph with a sample company, destructive).
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
> ⚠️ **The editor has no authentication.** Anyone who can reach it can rewrite the graph.
|
|
49
|
+
> The command binds to loopback and rejects requests whose `Host` header is not
|
|
50
|
+
> localhost. When you mount the app in your own server, put your own authentication in
|
|
51
|
+
> front of it, as below.
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
mount SuperAuth::Engine => '/super_auth'
|
|
53
|
+
In Rails the engine serves it. Mount the engine inside your own authentication:
|
|
48
54
|
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
```ruby
|
|
56
|
+
# config/routes.rb
|
|
57
|
+
authenticate :admin do # Devise; or a constraints block
|
|
58
|
+
mount SuperAuth::Engine => "/super_auth"
|
|
59
|
+
end
|
|
51
60
|
```
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
Or mount the app itself anywhere: `require "super_auth/editor"` and
|
|
63
|
+
`mount SuperAuth::Editor => "/wherever"`, again inside your authentication.
|
|
54
64
|
|
|
55
|
-
|
|
65
|
+
Mount it in any Rack app:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# config.ru
|
|
69
|
+
SuperAuth.db = Sequel.connect(ENV.fetch("SUPER_AUTH_DATABASE_URL"))
|
|
70
|
+
SuperAuth.load
|
|
71
|
+
map "/super_auth/editor" do
|
|
72
|
+
use Rack::Auth::Basic { |user, password| user == "admin" && password == ENV.fetch("EDITOR_PASSWORD") }
|
|
73
|
+
run SuperAuth::Editor
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Edits change the graph, not runtime access: `ByCurrentUser` and the row-level security
|
|
78
|
+
policies read the compiled `super_auth_authorizations` table. The strip at the top shows
|
|
79
|
+
that table's row count, and **Recompile** runs `SuperAuth::Authorization.compile!`
|
|
80
|
+
(`POST /api/compile`). The API is small and JSON: `GET /api/graph`,
|
|
81
|
+
`POST /api/nodes/:type`, `DELETE /api/nodes/:type/:id`, `POST /api/edges`,
|
|
82
|
+
`DELETE /api/edges/:id`, `POST /api/compile`. Writes must be `application/json`, and
|
|
83
|
+
the editor only creates edges of the eight kinds the path strategies read.
|
|
56
84
|
|
|
57
85
|
## Postgres Row-Level Security (optional)
|
|
58
86
|
|
|
@@ -77,8 +105,24 @@ SELECT super_auth_become(user_external_id => '42', user_external_type => 'AppUse
|
|
|
77
105
|
COMMIT; -- identity dies with the transaction; there is nothing to clear
|
|
78
106
|
```
|
|
79
107
|
|
|
80
|
-
For a user managed inside super_auth, pass `user_id => '7'` instead
|
|
81
|
-
|
|
108
|
+
For a user managed inside super_auth, pass `user_id => '7'` instead.
|
|
109
|
+
|
|
110
|
+
System context, which bypasses the policies (migrations, seeds, admin jobs), is a
|
|
111
|
+
separate function, so the right to bypass is granted per role rather than coming with
|
|
112
|
+
the right to assert an identity:
|
|
113
|
+
|
|
114
|
+
```sql
|
|
115
|
+
BEGIN;
|
|
116
|
+
SELECT super_auth_system();
|
|
117
|
+
-- every protected row is visible and writable --
|
|
118
|
+
COMMIT;
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`enable` revokes `EXECUTE` on `super_auth_system()` from `PUBLIC`; a role without an
|
|
122
|
+
explicit grant (`SuperAuth::RLS.grant_system(role)`) gets `permission denied`. Both
|
|
123
|
+
functions raise if the calling role is a superuser or has `BYPASSRLS`: Postgres exempts
|
|
124
|
+
those roles from every policy, so the assertion would protect nothing while looking
|
|
125
|
+
like it does.
|
|
82
126
|
|
|
83
127
|
The assertion is anchored to the calling transaction: `super_auth_become` sets
|
|
84
128
|
transaction-local identity settings plus a stamp of the current transaction id, and
|
|
@@ -121,16 +165,28 @@ class name when you use the AR integration).
|
|
|
121
165
|
|
|
122
166
|
**3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
|
|
123
167
|
policies entirely, so the app must not connect as one (owning the tables is fine —
|
|
124
|
-
the policies use `FORCE ROW LEVEL SECURITY`)
|
|
125
|
-
`
|
|
126
|
-
`
|
|
168
|
+
the policies use `FORCE ROW LEVEL SECURITY`); both identity functions refuse such a
|
|
169
|
+
role outright. `enable` grants every role what it needs on the gem's own tables
|
|
170
|
+
(`SELECT` on `super_auth_authorizations` and `super_auth_users`; `super_auth_become`
|
|
171
|
+
is executable by `PUBLIC`), so a runtime role needs privileges on your tables and
|
|
172
|
+
nothing else:
|
|
127
173
|
|
|
128
174
|
```sql
|
|
129
175
|
CREATE ROLE app_runtime LOGIN PASSWORD '...';
|
|
130
176
|
GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
|
|
131
|
-
GRANT SELECT ON super_auth_authorizations TO app_runtime;
|
|
132
177
|
```
|
|
133
178
|
|
|
179
|
+
The right to bypass the policies is separate. Grant it, from a migration or a
|
|
180
|
+
console, only to the roles that run migrations, seeds and admin jobs:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
SuperAuth::RLS.grant_system(:app_admin) # GRANT EXECUTE ON FUNCTION super_auth_system() TO app_admin
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
To keep the gem's tables readable only by specific roles instead, `REVOKE SELECT ON
|
|
187
|
+
super_auth_authorizations, super_auth_users FROM PUBLIC` and grant per role; the
|
|
188
|
+
policies run as the querying role, so it must keep that `SELECT`.
|
|
189
|
+
|
|
134
190
|
> ⚠️ **This is the one step that, if skipped, silently disables all protection.**
|
|
135
191
|
> PostgreSQL *always* lets **superusers** and roles with the **`BYPASSRLS`** attribute
|
|
136
192
|
> bypass row-level security. `FORCE ROW LEVEL SECURITY` only subjects the table *owner*
|
|
@@ -138,8 +194,9 @@ GRANT SELECT ON super_auth_authorizations TO app_runtime;
|
|
|
138
194
|
> Postgres as a superuser (the default in many local setups and some managed hosts), the
|
|
139
195
|
> policies apply to nobody and every row stays visible, while everything *looks* like it
|
|
140
196
|
> is working. Always connect as a dedicated non-superuser, non-`BYPASSRLS` role such as
|
|
141
|
-
> `app_runtime` above.
|
|
142
|
-
>
|
|
197
|
+
> `app_runtime` above. `super_auth_become()` and `super_auth_system()` refuse to run for
|
|
198
|
+
> such a role, so a misconfigured connection fails on its first identity assertion
|
|
199
|
+
> instead of silently seeing everything.
|
|
143
200
|
|
|
144
201
|
**4. Wrap work in an identity assertion.** In Ruby:
|
|
145
202
|
|
|
@@ -149,11 +206,25 @@ SuperAuth.as(current_user) do
|
|
|
149
206
|
end
|
|
150
207
|
```
|
|
151
208
|
|
|
152
|
-
`SuperAuth.as`
|
|
153
|
-
|
|
154
|
-
|
|
209
|
+
`SuperAuth.as` sets `SuperAuth.current_user` for the block as well, so the
|
|
210
|
+
`ByCurrentUser` scope and the policies agree, and restores both on the way out, nested
|
|
211
|
+
calls included. It opens a transaction and calls `super_auth_become` for you, or joins
|
|
212
|
+
the transaction you are already in — use it in an `around_action` (or around a job) to
|
|
213
|
+
cover a whole request. `auto_savepoint: true` makes every nested transaction a
|
|
214
|
+
savepoint (ActiveRecord's `joinable: false`), so each save inside commits on its own
|
|
215
|
+
and its `after_commit` hooks fire then; other keyword options pass through to Sequel's
|
|
216
|
+
`transaction`. Whether a write survives the block raising is up to you: rescue inside
|
|
217
|
+
the block to keep it. Where there is no block to wrap, a transaction you already
|
|
218
|
+
manage or a change of user mid-request, `SuperAuth::RLS.assert(user)` asserts the
|
|
219
|
+
identity in the current transaction and nothing else, and `SuperAuth::RLS.installed?`
|
|
220
|
+
reports whether `enable` has run, so no application needs to know the SQL functions'
|
|
221
|
+
signatures. Non-Ruby apps use the SQL contract directly. Each policy checks `super_auth_authorizations` with the same
|
|
155
222
|
semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
|
|
156
|
-
act as a wildcard, per-record authorizations match
|
|
223
|
+
act as a wildcard (deprecated, see CHANGELOG 0.8.0), per-record authorizations match
|
|
224
|
+
on id. Any object with an `id`
|
|
225
|
+
works as the user, including SuperAuth's own user records. For a user whose `system?`
|
|
226
|
+
is true, `SuperAuth.as` calls `super_auth_system()` instead, so the connection's role
|
|
227
|
+
must have been given the bypass with `SuperAuth::RLS.grant_system`.
|
|
157
228
|
|
|
158
229
|
### Notes
|
|
159
230
|
|
|
@@ -162,7 +233,11 @@ act as a wildcard, per-record authorizations match on id, `system?` users bypass
|
|
|
162
233
|
reach protected rows.
|
|
163
234
|
- Creating rows requires a type-level authorization for that resource type (or system
|
|
164
235
|
context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
|
|
165
|
-
`USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs.
|
|
236
|
+
`USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs. Type-level
|
|
237
|
+
nodes are deprecated (see CHANGELOG 0.8.0) but remain the only way to authorize
|
|
238
|
+
INSERT here until the parent-record grant planned for the next release; a resource
|
|
239
|
+
container does not replace one on a protected table, because a per-record row can
|
|
240
|
+
only match an id that already exists.
|
|
166
241
|
- The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
|
|
167
242
|
id even to read-only transactions — one extra xid per protected transaction.
|
|
168
243
|
Negligible for almost everyone; revisit with a virtual-xid variant only if
|
|
@@ -207,16 +282,16 @@ The basis for how this works is that the rules engine is trying to match a user
|
|
|
207
282
|
The engine determines if it can find an authorization route betewen a user and a resource. It does so by looking at users, groups, roles, permissions.
|
|
208
283
|
|
|
209
284
|
+---+ +---+
|
|
210
|
-
| | | | (Group
|
|
211
|
-
| v | v
|
|
285
|
+
| | | | (Group, Role and Resource
|
|
286
|
+
| v | v each nest within themselves)
|
|
212
287
|
+-------+ +------+
|
|
213
288
|
| Group |<----->| Role |
|
|
214
289
|
+-------+\ / +------+
|
|
215
290
|
^ \ / ^
|
|
216
291
|
| \/ |
|
|
217
|
-
| /\ |
|
|
218
|
-
| / \ |
|
|
219
|
-
V / \ V
|
|
292
|
+
| /\ | +---+
|
|
293
|
+
| / \ | | |
|
|
294
|
+
V / \ V | v
|
|
220
295
|
+---------------+ +------+/ \+------------+ +----------+ +-------------------+
|
|
221
296
|
| YourApp::User |<-->| User |<------>| Permission |<-->| Resource | <--> | YourApp::Resource |
|
|
222
297
|
+---------------+ +------+ +------------+ +----------+ +-------------------+
|
|
@@ -226,9 +301,10 @@ The engine determines if it can find an authorization route betewen a user and a
|
|
|
226
301
|
|
|
227
302
|
|
|
228
303
|
The lines between the boxes are called [edges](https://en.wikipedia.org/wiki/Glossary_of_graph_theory#edge).
|
|
229
|
-
The self-loops on `Group` and `
|
|
230
|
-
child `Group`s
|
|
231
|
-
|
|
304
|
+
The self-loops on `Group`, `Role` and `Resource` mean each nests within itself: a `Group`
|
|
305
|
+
can contain child `Group`s, a `Role` child `Role`s, and a `Resource` child `Resource`s (a
|
|
306
|
+
container with your records registered under it), recursively. Grants on a parent flow to
|
|
307
|
+
every descendant — which is why `Group`, `Role` and `Resource` are described as *trees*.
|
|
232
308
|
|
|
233
309
|
In general the super_auth has 5 different pathing strategies to search for access.
|
|
234
310
|
|
|
@@ -239,7 +315,7 @@ In general the super_auth has 5 different pathing strategies to search for acces
|
|
|
239
315
|
5. users <-> resource
|
|
240
316
|
|
|
241
317
|
Edges can be drawn between any 2 objects, allowing super_auth can seamlessly scale in complexity with you.
|
|
242
|
-
When `Group` and `
|
|
318
|
+
When `Group`, `Role` and `Resource` nodes are nested, the rules apply to all descendants. If there are any edges
|
|
243
319
|
between the specified user and the resource, then access is granted.
|
|
244
320
|
|
|
245
321
|
|
|
@@ -364,13 +440,18 @@ class Resource < ApplicationRecord
|
|
|
364
440
|
end
|
|
365
441
|
```
|
|
366
442
|
|
|
367
|
-
Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it:
|
|
443
|
+
Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it. Here the nodes sit in a container, so one edge covers every server registered under it:
|
|
368
444
|
|
|
369
445
|
```ruby
|
|
370
|
-
restartable = SuperAuth::Resource.create(
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
446
|
+
restartable = SuperAuth::Resource.create(name: "restartable servers") # a container
|
|
447
|
+
servers.each do |server|
|
|
448
|
+
SuperAuth::Resource.create(
|
|
449
|
+
name: server.name,
|
|
450
|
+
external_type: "Resource::ResourceRestartPermission",
|
|
451
|
+
external_id: server.id,
|
|
452
|
+
parent: restartable
|
|
453
|
+
)
|
|
454
|
+
end
|
|
374
455
|
restart = SuperAuth::Permission.create(name: "restart")
|
|
375
456
|
SuperAuth::Edge.create(user: sa_user, permission: restart)
|
|
376
457
|
SuperAuth::Edge.create(permission: restart, resource: restartable)
|
|
@@ -382,6 +463,8 @@ Resource::ResourceRestartPermission.find(id) # needs its own explicit approval
|
|
|
382
463
|
|
|
383
464
|
Grants are per class in both directions: a `"Resource"` grant does not unlock the subclass, and a `"Resource::ResourceRestartPermission"` grant does not unlock the base class.
|
|
384
465
|
|
|
466
|
+
The resource tree is containment, not inheritance. A row compiled through a container copies the descendant node's own `external_type`, so nesting does not weaken the rule above; what weakens it is the node's position. A `"Resource::ResourceRestartPermission"` node whose parent is the `"Resource"` node is a descendant of it and receives every grant drawn on `"Resource"`. Register capability nodes as siblings of their base-class nodes, or in a container beside them as above, never as their children.
|
|
467
|
+
|
|
385
468
|
## Row-Level Security for permission-gated models
|
|
386
469
|
|
|
387
470
|
For defense in depth on Postgres (13+), enable a policy on the table. It is keyed by a single resource type — the base class's name — and enforces *row visibility* using the same [contract described above](#postgres-row-level-security-optional):
|
|
@@ -399,7 +482,7 @@ end
|
|
|
399
482
|
# outside the block there is no asserted identity, so the policy matches nothing
|
|
400
483
|
```
|
|
401
484
|
|
|
402
|
-
Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level wildcard grants (`resource_external_id IS NULL
|
|
485
|
+
Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level wildcard grants (`resource_external_id IS NULL`, deprecated — see CHANGELOG 0.8.0) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
|
|
403
486
|
|
|
404
487
|
Because a policy sees only the table, not which Ruby class issued the query, row-level security enforces access to the **base** resource type: a `"Resource"` grant makes the row visible in the database, but the policy cannot distinguish the `"Resource::ResourceRestartPermission"` subclass. Per-class (capability) enforcement therefore stays with the ORM scope — the database is the row-visibility backstop, the client gates the capability.
|
|
405
488
|
|