super_auth 0.7.0 → 0.9.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 +100 -0
- data/Gemfile.lock +1 -1
- data/README.md +440 -42
- data/USAGE.md +127 -21
- data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
- data/db/migrate/12_add_resource_indexes.rb +116 -0
- data/db/migrate/13_add_resource_tree_guard.rb +28 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
- data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
- data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
- data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
- data/db/seeds/sample_data.rb +1 -0
- data/lib/generators/super_auth/install/templates/README +6 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +14 -3
- data/lib/super_auth/active_record/by_current_user.rb +136 -29
- data/lib/super_auth/active_record/group.rb +3 -0
- data/lib/super_auth/active_record/nested.rb +43 -0
- data/lib/super_auth/active_record/resource.rb +28 -4
- data/lib/super_auth/active_record/role.rb +3 -0
- data/lib/super_auth/active_record.rb +30 -2
- data/lib/super_auth/authorization.rb +63 -10
- data/lib/super_auth/edge.rb +73 -18
- data/lib/super_auth/editor/index.html +16 -10
- data/lib/super_auth/editor/seed.rb +11 -5
- data/lib/super_auth/editor.rb +35 -11
- data/lib/super_auth/nestable.rb +105 -4
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +71 -0
- data/lib/super_auth/rls.rb +576 -44
- data/lib/super_auth/tree_guard.rb +115 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +55 -2
- metadata +10 -1
data/USAGE.md
CHANGED
|
@@ -58,11 +58,11 @@ This creates an initializer at `config/initializers/super_auth.rb`. The SuperAut
|
|
|
58
58
|
**Step 3.** Copy the SuperAuth migrations into your app and run them:
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
rails railties:install:migrations
|
|
61
|
+
rails super_auth:install:migrations # the engine-scoped task; railties:install:migrations also works but copies every mounted engine's migrations
|
|
62
62
|
rails db:migrate
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
This creates the `super_auth_*` tables (users, groups, roles, permissions, resources, edges, authorizations) alongside your application's tables.
|
|
65
|
+
This creates the `super_auth_*` tables (users, groups, roles, permissions, resources, edges, authorizations) alongside your application's tables. The engine does not run its migrations by itself, so repeat both commands after upgrading to a version that ships a new one (0.8.0 added migration 11, `parent_id` on resources; 0.9.0 adds 12, two indexes, and 13, the resource tree guard). A table under Postgres row-level security also needs `SuperAuth::RLS.enable` re-run after a gem upgrade, since a policy already in the database does not change on its own — see "Keeping the policy current" in the README.
|
|
66
66
|
|
|
67
67
|
**Step 4.** Set the current user in your controller:
|
|
68
68
|
|
|
@@ -95,6 +95,8 @@ 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
|
+
A record that belongs to something can be reached through the column that says so, with no node per record: `super_auth parent: { column: :blog_id, resource_type: ["Blog::Member"] }` also admits every post whose `blog_id` the user holds a `Blog::Member` row for. See [Parent-record grants](#parent-record-grants-tenancy-from-a-column).
|
|
99
|
+
|
|
98
100
|
**Step 6 (optional).** Mount the engine, which serves the graph editor. It has no
|
|
99
101
|
authentication of its own, so mount it inside yours:
|
|
100
102
|
|
|
@@ -134,7 +136,7 @@ SuperAuth models authorization as a graph with 5 entity types:
|
|
|
134
136
|
| **Group** | Organizational units (teams, departments, etc) | Yes (nested) |
|
|
135
137
|
| **Role** | Job titles or permission sets | Yes (nested) |
|
|
136
138
|
| **Permission** | Actions (read, write, deploy, etc) | No |
|
|
137
|
-
| **Resource** | Things being protected (files, APIs, records) |
|
|
139
|
+
| **Resource** | Things being protected (files, APIs, records) | Yes (nested) |
|
|
138
140
|
|
|
139
141
|
**Edges** are connections drawn between any two entities. SuperAuth traverses the graph to find all valid paths from a User to a Resource. If a path exists, access is granted.
|
|
140
142
|
|
|
@@ -224,6 +226,8 @@ backend.ancestors_dataset.all # => [Engineering, Company]
|
|
|
224
226
|
company.descendants_dataset.all # => [Engineering, Backend, Frontend]
|
|
225
227
|
```
|
|
226
228
|
|
|
229
|
+
A node cannot be made its own parent or moved under one of its own descendants: the save fails validation (`parent_id is inside the node's own subtree, which would close a cycle`). A cycle would make every node in it an ancestor of every other, so a grant on any of them would reach all of their subtrees, and nothing would fail loudly. `compile!` refuses a table with a cycle in it too, naming the nodes, for writes that went around the model. The same holds for roles and resources.
|
|
230
|
+
|
|
227
231
|
### Roles (hierarchical)
|
|
228
232
|
|
|
229
233
|
Roles work exactly like Groups -- they support the same nesting.
|
|
@@ -245,20 +249,89 @@ write_perm = SuperAuth::Permission.create(name: "write")
|
|
|
245
249
|
deploy_perm = SuperAuth::Permission.create(name: "deploy")
|
|
246
250
|
```
|
|
247
251
|
|
|
248
|
-
### Resources
|
|
252
|
+
### Resources (hierarchical)
|
|
249
253
|
|
|
250
|
-
Resources represent what you are protecting.
|
|
254
|
+
Resources represent what you are protecting. A node with neither `external_type` nor `external_id` is a container; a node with both points at one record of your application. Resources nest like groups and roles, and a grant on a node reaches the node and every node under it, so the usual shape is a container per folder, project, tenant or whatever your application nests records under, with the records registered beneath it.
|
|
251
255
|
|
|
252
256
|
```ruby
|
|
253
|
-
#
|
|
257
|
+
# A named resource with nothing behind it
|
|
254
258
|
staging = SuperAuth::Resource.create(name: "staging")
|
|
255
259
|
|
|
256
|
-
#
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
external_type: "Post"
|
|
260
|
+
# A container, and a record of your app registered under it
|
|
261
|
+
reports = SuperAuth::Resource.create(name: "reports")
|
|
262
|
+
q3 = SuperAuth::Resource.create(
|
|
263
|
+
name: "Q3 report",
|
|
264
|
+
external_type: "Post",
|
|
265
|
+
external_id: post.id,
|
|
266
|
+
parent: reports
|
|
261
267
|
)
|
|
268
|
+
|
|
269
|
+
# A grant on the container reaches q3, and every node registered under
|
|
270
|
+
# reports later, as of the next compile!
|
|
271
|
+
SuperAuth::Edge.create(permission: read_perm, resource: reports)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Navigate the tree the same way as groups:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
SuperAuth::Resource.roots # nodes with no parent
|
|
278
|
+
reports.children_dataset.all # => [q3]
|
|
279
|
+
q3.parent # => reports
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The compiled row for `q3` carries `q3`'s own `external_type` and `external_id` whether the edge was drawn to `q3` or to `reports`: runtime reads the record a grant reaches and nothing about how it got there. There are no resource path columns in the compiled table; "granted through which container" is a question for the graph (`parent`, `children_dataset`) and the editor.
|
|
283
|
+
|
|
284
|
+
#### Type-level grants
|
|
285
|
+
|
|
286
|
+
A node with an `external_type` and no `external_id` is a type-level grant, or wildcard: at runtime it means every record of that type, present and future. `ByCurrentUser` skips per-record filtering when one matches, and the row-level security policy's type-level step does the same in the database. It is a supported, permanent primitive — "this principal may act on every record of this type" has no cheaper spelling, and a platform admin tier is made of them. One way they go wrong: a type string that resolves to no scoped model — the class was moved to a read-only base with a `Writable` subclass and the scope went with it, or the constant is gone — compiles rows that admit nobody. In Rails, `SuperAuth::ActiveRecord::Resource.dead_type_level_nodes` lists them.
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# Every Post, present and future
|
|
290
|
+
posts = SuperAuth::Resource.create(name: "posts", external_type: "Post")
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
What a type-level node may not do is join the tree. `compile!` refuses, with a `SuperAuth::Error` naming the node ids, a type-level node with a parent or children: nested in the tree it would reach every record of its type through its ancestors' grants, and nodes beneath it could be reached only through a grant that already covers them. The subtree walk never descends from one either, so a granted type-level node compiles to its own `(type, NULL)` row and nothing else, on every path. A type-level node stays at the root with no children, or gets an `external_id`.
|
|
294
|
+
|
|
295
|
+
Two finders, because the nil id is easy to reach by accident: `SuperAuth::Resource.wildcards` is the type-level nodes, and `SuperAuth::Resource.record("Post", post.id)` is the node for one record, which raises `SuperAuth::Error` on a nil id rather than answering with the type-level node — a helper called with an unset foreign key would otherwise grant, revoke or label the grant that covers every Post.
|
|
296
|
+
|
|
297
|
+
Destroying a node takes its compiled rows and its edges with it, in the same transaction. Children are left where they are (the foreign key refuses to orphan them), and rows compiled *through* the node for its descendants last until the next compile, as after any other revocation.
|
|
298
|
+
|
|
299
|
+
#### Parent-record grants (tenancy from a column)
|
|
300
|
+
|
|
301
|
+
A record that belongs to something — a post to a blog, a claim to an organization — can be reached through the column that says so, with no node per record and nothing to recompile when a record is created or moves. Declare the column and the types whose rows admit through it on the model (and, on Postgres, on the policy), then grant the parent's node as usual:
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
class Post < ApplicationRecord
|
|
305
|
+
super_auth parent: { column: :blog_id, resource_type: ["Blog::Member"] }
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# One node per blog, of a capability type nobody else is granted; grant it, compile
|
|
309
|
+
blog_node = SuperAuth::Resource.create(name: blog.title, external_type: "Blog::Member", external_id: blog.id)
|
|
310
|
+
SuperAuth::Edge.create(user: alice, resource: blog_node)
|
|
311
|
+
SuperAuth::ActiveRecord::Authorization.compile!
|
|
312
|
+
|
|
313
|
+
Post.where(blog_id: blog.id) # alice sees every post of the blog, present and future
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
The steps are OR'd: a per-record grant on one post still admits it, with or without a `blog_id`, and a type-level `Post` grant still admits everything. A type-level `Blog::Member` row admits nothing (a column holds an id; NULL equals none), and parents do not chain. The parent type is a capability type nobody else is granted — `Blog::Member`, never bare `Blog`, whose per-record node any grant on the blog reaches — and the list under the column names every tier that may touch the row at all, because the database policy must never be narrower than any tier's ORM scope. The full contract, the Postgres side, and the platform-only subclass that must declare no parent are in the README ("Postgres Row-Level Security" and "Permission-Gated Models").
|
|
317
|
+
|
|
318
|
+
##### Moving tenancy from per-record nodes to a parent column
|
|
319
|
+
|
|
320
|
+
A table that has been carrying one node per record per member — a post node for every blog member — can drop those rows for the column. Never delete per-record nodes wholesale: a node with a direct user->resource edge (an owner, a reader granted one record) is that user's only path, and `blog_id` says nothing about them. The rule is that a per-record node may go only when no user->resource edge points at it and no child sits under it; on Postgres, `SuperAuth::RLS.coverage(:posts)` reports what the change does before it is made — `widening` (records the parent step admits that no per-record row did), `loss` (what a type-level holder would lose), `null_parent` (records no parent grant can reach), `orphaned_rows`, and `deletable_nodes`, the rule above as a sample. Over the whole table the rule is one query, and `destroy` purges each node's compiled rows and edges as it goes. Where RLS is installed the work runs as the system user, since the policy hides the rows from a process with no identity:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
deletable = SuperAuth::Resource.where(external_type: "Post").exclude(external_id: nil).
|
|
324
|
+
exclude(id: SuperAuth::Edge.exclude(user_id: nil).exclude(resource_id: nil).select(:resource_id)).
|
|
325
|
+
exclude(id: SuperAuth::Resource.exclude(parent_id: nil).select(:parent_id))
|
|
326
|
+
|
|
327
|
+
migrate = proc { deletable.each(&:destroy) }
|
|
328
|
+
|
|
329
|
+
if SuperAuth::RLS.installed?
|
|
330
|
+
SuperAuth.as(SuperAuth::User.system, &migrate)
|
|
331
|
+
else
|
|
332
|
+
SuperAuth.db.transaction(&migrate)
|
|
333
|
+
end
|
|
334
|
+
SuperAuth::Authorization.compile! # SuperAuth::ActiveRecord::Authorization.compile! in Rails
|
|
262
335
|
```
|
|
263
336
|
|
|
264
337
|
## Drawing Edges
|
|
@@ -311,6 +384,8 @@ SuperAuth automatically evaluates 5 pathing strategies and unions the results. Y
|
|
|
311
384
|
|
|
312
385
|
When groups or roles are nested, SuperAuth considers the full tree. If you assign a user to a parent group, they can access resources through roles attached to that group *and all its descendants*.
|
|
313
386
|
|
|
387
|
+
Resources nest too, at the other end of the path: a grant on a container reaches every node registered under it (see [Resources](#resources-hierarchical)).
|
|
388
|
+
|
|
314
389
|
```ruby
|
|
315
390
|
# Bethany is in Company (the root group)
|
|
316
391
|
SuperAuth::Edge.create(user: bethany, group: company)
|
|
@@ -349,6 +424,8 @@ auth[:resource_id] # Integer
|
|
|
349
424
|
auth[:resource_name] # "staging"
|
|
350
425
|
```
|
|
351
426
|
|
|
427
|
+
`resource_id` and `resource_name` are the node the grant reaches: a row compiled through a container names the descendant, not the container, and there is no resource path column (see [Resources](#resources-hierarchical)).
|
|
428
|
+
|
|
352
429
|
### Filter by user
|
|
353
430
|
|
|
354
431
|
```ruby
|
|
@@ -461,7 +538,11 @@ Post::PostPublishPermission.find(id) # needs a "Post::PostPublishPermission" gra
|
|
|
461
538
|
|
|
462
539
|
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
540
|
|
|
464
|
-
|
|
541
|
+
The resource tree is containment, not inheritance. A row compiled through a container copies the descendant node's own `external_type`, which is why the rule above survives nesting — but a `"Post::PostPublishPermission"` node registered *under* the `"Post"` node is a descendant of it and receives every grant on `"Post"`. Register capability nodes as siblings of their base-class nodes, or in a container beside them, never as their children.
|
|
542
|
+
|
|
543
|
+
A subclass inherits a `parent:` declaration and is still keyed on its own name; re-declaring on the subclass replaces its parents alone, on the one inherited scope. A subclass that exists to be *narrower* than the record's owner — actions even the owner may not take — declares no parent, ever: `Claim::Admin` keyed on `Organization::Admin` "for symmetry" would hand every organization admin those actions on their own organization's claims. The README's "Permission-Gated Models" shows `Claim::Admin` (platform-only, no parent) and `Organization::Admin` (a per-organization node, a legitimate parent type) side by side, because the names collide and the meanings are opposite.
|
|
544
|
+
|
|
545
|
+
For database-side enforcement of the same rules see "Postgres Row-Level Security" in the README (`SuperAuth::RLS.enable` takes the same `resource_type:` and `parent:`, and lists every class that scopes the table and every tier's parent type, since the policy must never be narrower than any tier's ORM scope).
|
|
465
546
|
|
|
466
547
|
### Linking to your app's models
|
|
467
548
|
|
|
@@ -475,14 +556,25 @@ sa_user = SuperAuth::User.create(
|
|
|
475
556
|
external_type: "User"
|
|
476
557
|
)
|
|
477
558
|
|
|
478
|
-
# Link a SuperAuth resource to
|
|
559
|
+
# Link a SuperAuth resource to one Post, in a container beside its siblings
|
|
560
|
+
reports = SuperAuth::Resource.create(name: "reports")
|
|
479
561
|
sa_resource = SuperAuth::Resource.create(
|
|
480
|
-
name:
|
|
481
|
-
external_type: "Post"
|
|
562
|
+
name: post.title,
|
|
563
|
+
external_type: "Post",
|
|
564
|
+
external_id: post.id,
|
|
565
|
+
parent: reports
|
|
482
566
|
)
|
|
567
|
+
SuperAuth::Resource.record("Post", post.id) # finds it again; raises on a nil id rather than
|
|
568
|
+
# answering with the type-level "Post" node
|
|
569
|
+
|
|
570
|
+
# Or reach posts through the blog they belong to, with no node per post
|
|
571
|
+
class Post < ApplicationRecord
|
|
572
|
+
super_auth parent: { column: :blog_id, resource_type: ["Blog::Member"] }
|
|
573
|
+
end
|
|
574
|
+
SuperAuth::Resource.create(name: blog.title, external_type: "Blog::Member", external_id: blog.id)
|
|
483
575
|
```
|
|
484
576
|
|
|
485
|
-
When `super_auth` is included in a model, the default scope matches the current user's `id` and class name against `external_id` / `external_type` in the authorizations table. This means your application user objects work directly -- no need to convert to SuperAuth users in the controller.
|
|
577
|
+
When `super_auth` is included in a model, the default scope matches the current user's `id` and class name against `external_id` / `external_type` in the authorizations table. This means your application user objects work directly -- no need to convert to SuperAuth users in the controller. On the resource side it matches the record's class name and `id` against `resource_external_type` / `resource_external_id`; a node with the type and no id matches every record of the type, the type-level grant described under [Resources](#resources-hierarchical); and each `parent:` column is matched against the rows of its own types, so a `Blog::Member` row for blog 3 admits every post whose `blog_id` is 3.
|
|
486
578
|
|
|
487
579
|
### ActiveRecord models
|
|
488
580
|
|
|
@@ -535,12 +627,26 @@ deployers = SuperAuth::Edge.authorizations.all.select { |a|
|
|
|
535
627
|
deployers.map { |a| a[:user_name] }.uniq
|
|
536
628
|
```
|
|
537
629
|
|
|
630
|
+
**"Why can Alice see this post?"** — once a model declares `parent:`, the compiled table alone no longer answers this: a row is admitted by a compiled row naming a *different* record (the blog), so the answer is a join through the posts table, and `explain` is that join:
|
|
631
|
+
|
|
632
|
+
```ruby
|
|
633
|
+
SuperAuth.current_user = alice
|
|
634
|
+
Post.super_auth_explain(post)
|
|
635
|
+
# => [{ step: :blog_id, user_external_id: 42, user_external_type: "User",
|
|
636
|
+
# resource_external_type: "Blog::Member", resource_external_id: 3, ... }]
|
|
637
|
+
# steps: :type_level, :id, or a parent column; [{ step: :system }] for the system user
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
On Postgres `SuperAuth::RLS.explain(:posts, post.id)` answers the same for the identity asserted on the connection, with no model loaded.
|
|
641
|
+
|
|
538
642
|
## Visualization
|
|
539
643
|
|
|
540
644
|
The graph editor shows the whole graph as five boxes (groups, roles, users,
|
|
541
|
-
permissions, resources)
|
|
542
|
-
it
|
|
543
|
-
|
|
645
|
+
permissions, resources), drawing groups, roles and resources as trees; click any record
|
|
646
|
+
to trace what it can reach and what reaches it, connect records to draw edges, create
|
|
647
|
+
records (including a resource container under a chosen parent), delete records and
|
|
648
|
+
edges, and recompile. The editor makes containers; your application registers records
|
|
649
|
+
under them. See the README's "Graph editor" section for the full description.
|
|
544
650
|
|
|
545
651
|
- Rails: mount the engine inside your own authentication (Step 6 above) and open
|
|
546
652
|
`http://localhost:3000/super_auth`.
|
|
@@ -577,8 +683,8 @@ write = SuperAuth::Permission.create(name: "write")
|
|
|
577
683
|
deploy = SuperAuth::Permission.create(name: "deploy")
|
|
578
684
|
|
|
579
685
|
# Resources
|
|
580
|
-
api = SuperAuth::Resource.create(name: "api"
|
|
581
|
-
dashboard = SuperAuth::Resource.create(name: "dashboard"
|
|
686
|
+
api = SuperAuth::Resource.create(name: "api")
|
|
687
|
+
dashboard = SuperAuth::Resource.create(name: "dashboard")
|
|
582
688
|
prod_db = SuperAuth::Resource.create(name: "production_db")
|
|
583
689
|
|
|
584
690
|
# Users
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
# Resources nest like groups and roles (2_groups.rb): same integer type as
|
|
3
|
+
# the pk, deferrable only where supported. The index is skipped on MySQL
|
|
4
|
+
# for the reason in 8_add_indexes_to_edges.rb — InnoDB indexes the foreign
|
|
5
|
+
# key column itself and will not drop that index while the constraint
|
|
6
|
+
# stands. Both branches compute database_type before alter_table: inside
|
|
7
|
+
# the block self is the generator.
|
|
8
|
+
up do
|
|
9
|
+
is_postgres = database_type == :postgres
|
|
10
|
+
is_mysql = [:mysql, :mysql2].include?(database_type)
|
|
11
|
+
|
|
12
|
+
alter_table(:super_auth_resources) do
|
|
13
|
+
if is_postgres
|
|
14
|
+
add_foreign_key :parent_id, :super_auth_resources, deferrable: true, type: :integer
|
|
15
|
+
else
|
|
16
|
+
add_foreign_key :parent_id, :super_auth_resources, type: :integer
|
|
17
|
+
end
|
|
18
|
+
add_index :parent_id unless is_mysql
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# drop_foreign_key drops the constraint and then the column; MySQL refuses
|
|
23
|
+
# to drop a column a constraint still depends on.
|
|
24
|
+
down do
|
|
25
|
+
is_mysql = [:mysql, :mysql2].include?(database_type)
|
|
26
|
+
|
|
27
|
+
alter_table(:super_auth_resources) do
|
|
28
|
+
drop_index :parent_id unless is_mysql
|
|
29
|
+
drop_foreign_key :parent_id
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
# Four lookups that ran as sequence scans. Two are the host's own work
|
|
3
|
+
# around a record: the compiled table by the resource a row names, and the
|
|
4
|
+
# resources table by the record a node points at, which every finder for a
|
|
5
|
+
# record's node runs. The first leads on the id, not the type: a host's
|
|
6
|
+
# per-record compile is keyed by record, and one record carries several
|
|
7
|
+
# nodes of different types (Claim and Claim::Writable for one claim), so the
|
|
8
|
+
# hot slice is "every row for these ids, whatever their type" — id-only by
|
|
9
|
+
# construction, and a type-leading index can only scan and filter it.
|
|
10
|
+
#
|
|
11
|
+
# The other two are the policy's own, and they are Postgres only, since the
|
|
12
|
+
# policy is and an expression index is not portable. Every step of the
|
|
13
|
+
# policy matches the asserted identity by casting the column —
|
|
14
|
+
# a.user_external_id::text and a.user_id::text, cast on the column because
|
|
15
|
+
# casting the setting would turn a malformed identity into an error inside
|
|
16
|
+
# every query — and a cast on a column defeats a plain btree unless it is a
|
|
17
|
+
# no-op. So idx_sa_auth_by_current_user (migration 9) serves the external
|
|
18
|
+
# half only where SuperAuth.external_id_type is a text type, and nothing has
|
|
19
|
+
# ever indexed user_id at all. On a uuid or bigint host that is one full
|
|
20
|
+
# pass over super_auth_authorizations per half per step, once per statement
|
|
21
|
+
# on every protected table. These two index the expressions the policy
|
|
22
|
+
# actually writes, so each half seeks instead.
|
|
23
|
+
#
|
|
24
|
+
# The internal half is built on every Postgres host: user_id is int4 in
|
|
25
|
+
# every install (migration 7), int4->text is CoerceViaIO, and `holdings`
|
|
26
|
+
# emits both identity halves for every step whatever kind of identity is
|
|
27
|
+
# asserted, so an install that only ever asserts an external user pays for
|
|
28
|
+
# this one too. The external half is built only where the column's
|
|
29
|
+
# catalogue type is neither varchar nor text: varchar->text is a
|
|
30
|
+
# RelabelType, so on the default :string install migration 9's plain btree
|
|
31
|
+
# already answers the cast as a seek and this index is pure duplication —
|
|
32
|
+
# 47 MB beside migration 9's 39 MB, and roughly +580 ms per 200,000 rows
|
|
33
|
+
# written at compile. The catalogue is asked rather than
|
|
34
|
+
# SuperAuth.external_id_type, because the column is what the policy casts
|
|
35
|
+
# and a host may have altered it since; a column the query cannot find gets
|
|
36
|
+
# the index, since the failure that matters is not having one.
|
|
37
|
+
#
|
|
38
|
+
# The write cost of the pair is approximate — a 200,000-row compile insert
|
|
39
|
+
# against a 999,973-row uuid table, individual runs 920-2619 ms: two
|
|
40
|
+
# indexes 1158 ms (~173k rows/s, 20 MB), four 2538 ms (~79k rows/s, 45 MB),
|
|
41
|
+
# so about +7 s and +25 MB per 1,000,000 rows compiled.
|
|
42
|
+
#
|
|
43
|
+
# CONCURRENTLY on Postgres, which is why the whole migration runs outside a
|
|
44
|
+
# transaction. A host large enough to need these cannot take an ACCESS
|
|
45
|
+
# EXCLUSIVE lock on super_auth_authorizations: it sits on the read path of
|
|
46
|
+
# every statement on every protected table, so a plain CREATE INDEX stalls
|
|
47
|
+
# the application for the length of the build. The price is that a failed
|
|
48
|
+
# build leaves an INVALID index behind, holding the name and used by no
|
|
49
|
+
# planner; re-running the migration is the repair, because the check below
|
|
50
|
+
# drops an index of one of these names that Postgres marks invalid and
|
|
51
|
+
# builds it again. That is the one case where a name a host may own is not
|
|
52
|
+
# left alone, and it costs the host nothing: an invalid index answers no
|
|
53
|
+
# query, and nothing can take its name while it stands. MySQL 8 builds an
|
|
54
|
+
# index online by default and SQLite is irrelevant at this scale, so both
|
|
55
|
+
# keep a plain add_index.
|
|
56
|
+
no_transaction
|
|
57
|
+
|
|
58
|
+
up do
|
|
59
|
+
is_postgres = database_type == :postgres
|
|
60
|
+
concurrent = is_postgres ? { concurrently: true } : {}
|
|
61
|
+
|
|
62
|
+
# True when the name is taken and the migration should leave it alone. On
|
|
63
|
+
# Postgres the catalogue is the right question rather than the table's
|
|
64
|
+
# index list: an index name is unique per schema, so a name taken on any
|
|
65
|
+
# table blocks this one, and Sequel's `indexes` reports neither an
|
|
66
|
+
# invalid index nor an index on an expression.
|
|
67
|
+
taken = lambda do |table, name|
|
|
68
|
+
next indexes(table).key?(name) unless is_postgres
|
|
69
|
+
|
|
70
|
+
row = fetch("SELECT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass(?)", name.to_s).first
|
|
71
|
+
next false unless row
|
|
72
|
+
next true if row[:indisvalid]
|
|
73
|
+
|
|
74
|
+
run "DROP INDEX CONCURRENTLY IF EXISTS #{literal(Sequel.identifier(name.to_s))}"
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
unless taken.call(:super_auth_authorizations, :idx_sa_auth_by_resource)
|
|
79
|
+
add_index :super_auth_authorizations, [:resource_external_id, :resource_external_type],
|
|
80
|
+
name: :idx_sa_auth_by_resource, **concurrent
|
|
81
|
+
end
|
|
82
|
+
unless taken.call(:super_auth_resources, :idx_sa_resources_by_external)
|
|
83
|
+
add_index :super_auth_resources, [:external_type, :external_id],
|
|
84
|
+
name: :idx_sa_resources_by_external, **concurrent
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
next unless is_postgres
|
|
88
|
+
|
|
89
|
+
unless taken.call(:super_auth_authorizations, :idx_sa_auth_by_internal_user_text)
|
|
90
|
+
add_index :super_auth_authorizations,
|
|
91
|
+
[Sequel.lit("(user_id::text)"), :resource_external_type, :resource_external_id],
|
|
92
|
+
name: :idx_sa_auth_by_internal_user_text, concurrently: true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
row = fetch(<<~SQL).first
|
|
96
|
+
SELECT t.typname FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
|
|
97
|
+
WHERE a.attrelid = to_regclass('super_auth_authorizations')
|
|
98
|
+
AND a.attname = 'user_external_id' AND a.attnum > 0 AND NOT a.attisdropped
|
|
99
|
+
SQL
|
|
100
|
+
external_is_text = row && %w[varchar text].include?(row[:typname])
|
|
101
|
+
|
|
102
|
+
unless external_is_text || taken.call(:super_auth_authorizations, :idx_sa_auth_by_current_user_text)
|
|
103
|
+
add_index :super_auth_authorizations,
|
|
104
|
+
[Sequel.lit("(user_external_id::text)"), :user_external_type, :resource_external_type, :resource_external_id],
|
|
105
|
+
name: :idx_sa_auth_by_current_user_text, concurrently: true
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# No down: up skipped an index a host already owned under the same name,
|
|
110
|
+
# and a migration cannot tell afterwards which of the two it created, so a
|
|
111
|
+
# rollback that dropped by name would take a host's own index with it. An
|
|
112
|
+
# index left behind costs nothing; a lost one costs a sequence scan on a
|
|
113
|
+
# hot path. The tables' own drop removes them on a full uninstall.
|
|
114
|
+
down do
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Sequel.migration do
|
|
2
|
+
# A parent_id cycle in super_auth_resources is a security defect, not
|
|
3
|
+
# untidiness: every node in a cycle is an ancestor of every other, so a
|
|
4
|
+
# grant on any of them reaches all of their subtrees, and the walks
|
|
5
|
+
# terminate on a cycle (UNION), so nothing fails loudly. The models refuse
|
|
6
|
+
# the shape (SuperAuth::Nestable validate) and compile! refuses to run on it
|
|
7
|
+
# (assert_acyclic!). This trigger is the third line, for writes that go
|
|
8
|
+
# around both — a raw UPDATE, a data migration, another language — and
|
|
9
|
+
# refuses them at the row, with the same two rules: a node is not its own
|
|
10
|
+
# parent, and its new parent is not inside its own subtree. The walk goes
|
|
11
|
+
# UP from the new parent with UNION, so a cycle already in the table that
|
|
12
|
+
# does not include the row terminates instead of looping.
|
|
13
|
+
#
|
|
14
|
+
# Postgres only. SQLite and MySQL both have triggers, each in a dialect of
|
|
15
|
+
# its own with its own limits on what a trigger body may do, and the model
|
|
16
|
+
# and compile guards already hold there; a second and third implementation
|
|
17
|
+
# of the same check is not worth what it costs to carry. On those two this
|
|
18
|
+
# migration does nothing, and says so here rather than in a gap in the
|
|
19
|
+
# numbering. The SQL lives in SuperAuth::TreeGuard, which a host also calls
|
|
20
|
+
# from its test setup: db/schema.rb cannot carry a trigger.
|
|
21
|
+
up do
|
|
22
|
+
SuperAuth::TreeGuard.install(db: self)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
down do
|
|
26
|
+
SuperAuth::TreeGuard.remove(db: self)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -4,7 +4,13 @@ class CreateSuperAuthUsers < ActiveRecord::Migration[7.0]
|
|
|
4
4
|
t.column :external_id, SuperAuth.external_id_type
|
|
5
5
|
t.string :external_type
|
|
6
6
|
t.string :name
|
|
7
|
-
|
|
7
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
8
|
+
# refuses a datetime(6) whose default does not name the same
|
|
9
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
10
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
11
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
12
|
+
# on Postgres and SQLite it changes nothing.
|
|
13
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
8
14
|
end
|
|
9
15
|
end
|
|
10
16
|
end
|
|
@@ -3,7 +3,13 @@ class CreateSuperAuthGroups < ActiveRecord::Migration[7.0]
|
|
|
3
3
|
create_table :super_auth_groups do |t|
|
|
4
4
|
t.string :name, null: false
|
|
5
5
|
t.bigint :parent_id
|
|
6
|
-
|
|
6
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
7
|
+
# refuses a datetime(6) whose default does not name the same
|
|
8
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
9
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
10
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
11
|
+
# on Postgres and SQLite it changes nothing.
|
|
12
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
7
13
|
end
|
|
8
14
|
|
|
9
15
|
add_foreign_key :super_auth_groups, :super_auth_groups, column: :parent_id
|
|
@@ -2,7 +2,13 @@ class CreateSuperAuthPermissions < ActiveRecord::Migration[7.0]
|
|
|
2
2
|
def change
|
|
3
3
|
create_table :super_auth_permissions do |t|
|
|
4
4
|
t.string :name, null: false
|
|
5
|
-
|
|
5
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
6
|
+
# refuses a datetime(6) whose default does not name the same
|
|
7
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
8
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
9
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
10
|
+
# on Postgres and SQLite it changes nothing.
|
|
11
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
6
12
|
end
|
|
7
13
|
end
|
|
8
14
|
end
|
|
@@ -3,7 +3,13 @@ class CreateSuperAuthRoles < ActiveRecord::Migration[7.0]
|
|
|
3
3
|
create_table :super_auth_roles do |t|
|
|
4
4
|
t.string :name, null: false
|
|
5
5
|
t.bigint :parent_id
|
|
6
|
-
|
|
6
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
7
|
+
# refuses a datetime(6) whose default does not name the same
|
|
8
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
9
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
10
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
11
|
+
# on Postgres and SQLite it changes nothing.
|
|
12
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
7
13
|
end
|
|
8
14
|
|
|
9
15
|
add_foreign_key :super_auth_roles, :super_auth_roles, column: :parent_id
|
|
@@ -4,7 +4,13 @@ class CreateSuperAuthResources < ActiveRecord::Migration[7.0]
|
|
|
4
4
|
t.string :name
|
|
5
5
|
t.column :external_id, SuperAuth.external_id_type
|
|
6
6
|
t.string :external_type
|
|
7
|
-
|
|
7
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
8
|
+
# refuses a datetime(6) whose default does not name the same
|
|
9
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
10
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
11
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
12
|
+
# on Postgres and SQLite it changes nothing.
|
|
13
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
8
14
|
end
|
|
9
15
|
end
|
|
10
16
|
end
|
|
@@ -6,7 +6,13 @@ class CreateSuperAuthEdges < ActiveRecord::Migration[7.0]
|
|
|
6
6
|
t.references :permission, foreign_key: { to_table: :super_auth_permissions }, null: true
|
|
7
7
|
t.references :role, foreign_key: { to_table: :super_auth_roles }, null: true
|
|
8
8
|
t.references :resource, foreign_key: { to_table: :super_auth_resources }, null: true
|
|
9
|
-
|
|
9
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
10
|
+
# refuses a datetime(6) whose default does not name the same
|
|
11
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
12
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
13
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
14
|
+
# on Postgres and SQLite it changes nothing.
|
|
15
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
10
16
|
end
|
|
11
17
|
end
|
|
12
18
|
end
|
|
@@ -35,7 +35,13 @@ class CreateSuperAuthAuthorizations < ActiveRecord::Migration[7.0]
|
|
|
35
35
|
t.column :resource_external_id, SuperAuth.external_id_type
|
|
36
36
|
t.string :resource_external_type
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
# precision: nil so MySQL emits datetime, not datetime(6): MySQL 8
|
|
39
|
+
# refuses a datetime(6) whose default does not name the same
|
|
40
|
+
# precision ("Invalid default value for created_at"), which stopped
|
|
41
|
+
# the chain at migration 1 and made the gem uninstallable there. It
|
|
42
|
+
# also matches the Sequel twin, whose DateTime is datetime on MySQL;
|
|
43
|
+
# on Postgres and SQLite it changes nothing.
|
|
44
|
+
t.timestamps precision: nil, default: -> { "CURRENT_TIMESTAMP" }
|
|
39
45
|
end
|
|
40
46
|
end
|
|
41
47
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddParentIdToSuperAuthResources < ActiveRecord::Migration[7.0]
|
|
2
|
+
# Mirrors db/migrate/11_add_parent_id_to_resources.rb. No index on MySQL:
|
|
3
|
+
# InnoDB indexes the foreign key column itself.
|
|
4
|
+
def change
|
|
5
|
+
add_column :super_auth_resources, :parent_id, :bigint
|
|
6
|
+
add_foreign_key :super_auth_resources, :super_auth_resources, column: :parent_id
|
|
7
|
+
add_index :super_auth_resources, :parent_id unless connection.adapter_name.match?(/mysql|trilogy/i)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
class AddSuperAuthResourceIndexes < ActiveRecord::Migration[7.0]
|
|
2
|
+
# Mirrors db/migrate/12_add_resource_indexes.rb, which says why: the
|
|
3
|
+
# compiled table by the resource a row names (id first — a per-record
|
|
4
|
+
# compile slices by ids across several node types), the resources table by
|
|
5
|
+
# the record a node points at, and, on Postgres only, the two expressions
|
|
6
|
+
# the policy's identity halves compare (user_external_id::text and
|
|
7
|
+
# user_id::text), which no plain btree can serve on a uuid or bigint host.
|
|
8
|
+
# The user_id half is built on every Postgres host, because `holdings`
|
|
9
|
+
# emits both identity halves for every step whatever kind of identity is
|
|
10
|
+
# asserted; the user_external_id half is built only where that column's
|
|
11
|
+
# catalogue type is neither varchar nor text, since varchar->text is a
|
|
12
|
+
# RelabelType and migration 9's plain btree already answers the cast as a
|
|
13
|
+
# seek there — a duplicate index of 47 MB and roughly +580 ms per 200,000
|
|
14
|
+
# rows written at compile. The catalogue is asked rather than
|
|
15
|
+
# SuperAuth.external_id_type, because the column is what the policy casts.
|
|
16
|
+
# Approximate write cost of the pair, on a uuid host: a 200,000-row compile
|
|
17
|
+
# insert goes from 1158 ms with two indexes to 2538 ms with four, about
|
|
18
|
+
# +7 s and +25 MB per 1,000,000 rows compiled.
|
|
19
|
+
#
|
|
20
|
+
# CONCURRENTLY on Postgres, so this migration runs outside a transaction:
|
|
21
|
+
# super_auth_authorizations is on the read path of every statement on every
|
|
22
|
+
# protected table, and a plain CREATE INDEX holds ACCESS EXCLUSIVE on it for
|
|
23
|
+
# the length of the build. A failed build leaves an INVALID index holding
|
|
24
|
+
# the name; re-running the migration is the repair, since the check below
|
|
25
|
+
# drops an invalid index of one of these names and builds it again. MySQL 8
|
|
26
|
+
# builds an index online by default and SQLite is irrelevant at this scale,
|
|
27
|
+
# so both keep a plain add_index.
|
|
28
|
+
disable_ddl_transaction!
|
|
29
|
+
|
|
30
|
+
def up
|
|
31
|
+
postgres = connection.adapter_name.match?(/postgres/i)
|
|
32
|
+
concurrent = postgres ? { algorithm: :concurrently } : {}
|
|
33
|
+
|
|
34
|
+
unless taken?(:super_auth_authorizations, :idx_sa_auth_by_resource, postgres)
|
|
35
|
+
add_index :super_auth_authorizations, [:resource_external_id, :resource_external_type],
|
|
36
|
+
name: :idx_sa_auth_by_resource, **concurrent
|
|
37
|
+
end
|
|
38
|
+
unless taken?(:super_auth_resources, :idx_sa_resources_by_external, postgres)
|
|
39
|
+
add_index :super_auth_resources, [:external_type, :external_id],
|
|
40
|
+
name: :idx_sa_resources_by_external, **concurrent
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return unless postgres
|
|
44
|
+
|
|
45
|
+
unless taken?(:super_auth_authorizations, :idx_sa_auth_by_internal_user_text, postgres)
|
|
46
|
+
add_index :super_auth_authorizations,
|
|
47
|
+
"(user_id::text), resource_external_type, resource_external_id",
|
|
48
|
+
name: :idx_sa_auth_by_internal_user_text, algorithm: :concurrently
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
typname = connection.select_value(<<~SQL)
|
|
52
|
+
SELECT t.typname FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid
|
|
53
|
+
WHERE a.attrelid = to_regclass('super_auth_authorizations')
|
|
54
|
+
AND a.attname = 'user_external_id' AND a.attnum > 0 AND NOT a.attisdropped
|
|
55
|
+
SQL
|
|
56
|
+
return if %w[varchar text].include?(typname)
|
|
57
|
+
|
|
58
|
+
unless taken?(:super_auth_authorizations, :idx_sa_auth_by_current_user_text, postgres)
|
|
59
|
+
add_index :super_auth_authorizations,
|
|
60
|
+
"(user_external_id::text), user_external_type, resource_external_type, resource_external_id",
|
|
61
|
+
name: :idx_sa_auth_by_current_user_text, algorithm: :concurrently
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# No down, for the reason in the Sequel twin: up skipped an index the host
|
|
66
|
+
# already owned under the same name, and a rollback dropping by name would
|
|
67
|
+
# take it with it.
|
|
68
|
+
def down
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# True when the name is taken and this migration should leave it alone. On
|
|
74
|
+
# Postgres the catalogue answers it: an index name is unique per schema, and
|
|
75
|
+
# index_name_exists? reports neither an invalid index nor one on an
|
|
76
|
+
# expression. An invalid index — what a failed CREATE INDEX CONCURRENTLY
|
|
77
|
+
# leaves — is dropped rather than kept, since it answers no query and no
|
|
78
|
+
# other index can take its name while it stands.
|
|
79
|
+
def taken?(table, name, postgres)
|
|
80
|
+
return connection.index_name_exists?(table, name) unless postgres
|
|
81
|
+
|
|
82
|
+
valid = connection.select_value("SELECT i.indisvalid FROM pg_index i WHERE i.indexrelid = to_regclass(#{connection.quote(name.to_s)})")
|
|
83
|
+
return false if valid.nil?
|
|
84
|
+
return true if ActiveRecord::Type::Boolean.new.cast(valid)
|
|
85
|
+
|
|
86
|
+
connection.execute("DROP INDEX CONCURRENTLY IF EXISTS #{connection.quote_table_name(name.to_s)}")
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
end
|