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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +8 -1
- data/README.md +286 -22
- data/USAGE.md +41 -16
- data/config/routes.rb +9 -71
- data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
- data/db/migrate/1_users.rb +1 -1
- data/db/migrate/5_resources.rb +1 -1
- data/db/migrate/7_authorization.rb +2 -2
- data/db/migrate/8_add_indexes_to_edges.rb +7 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
- 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/install/templates/super_auth.rb +16 -5
- data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +15 -0
- data/lib/super_auth/active_record/by_current_user.rb +25 -5
- 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/edge.rb +65 -100
- 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/nestable.rb +41 -3
- data/lib/super_auth/railtie.rb +0 -2
- data/lib/super_auth/rls.rb +256 -0
- data/lib/super_auth/user.rb +3 -1
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +85 -0
- data/lib/tasks/super_auth_tasks.rake +28 -0
- metadata +15 -9
- 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,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.
|
|
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
|
[](https://github.com/JonathanFrias/super_auth/actions)
|
|
4
4
|
|
|
5
|
-
Super auth is turn-key authorization
|
|
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
|
|
8
|
-
access control is the NUMBER 1 most common security risk in modern applications today. super_auth provides
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
58
|
+
Or mount the app itself anywhere: `require "super_auth/editor"` and
|
|
59
|
+
`mount SuperAuth::Editor => "/wherever"`, again inside your authentication.
|
|
26
60
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
rails
|
|
153
|
+
rails generate super_auth:rls Document Invoice
|
|
154
|
+
rails db:migrate
|
|
155
|
+
```
|
|
38
156
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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/
|
|
644
|
+
SuperAuth is available as open source under the [GPL v2 License](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html).
|