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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +440 -42
  5. data/USAGE.md +127 -21
  6. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  7. data/db/migrate/12_add_resource_indexes.rb +116 -0
  8. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  9. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  10. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  11. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  12. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  13. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  14. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  16. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  17. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  18. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  19. data/db/seeds/sample_data.rb +1 -0
  20. data/lib/generators/super_auth/install/templates/README +6 -2
  21. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  22. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  23. data/lib/super_auth/active_record/authorization.rb +14 -3
  24. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  25. data/lib/super_auth/active_record/group.rb +3 -0
  26. data/lib/super_auth/active_record/nested.rb +43 -0
  27. data/lib/super_auth/active_record/resource.rb +28 -4
  28. data/lib/super_auth/active_record/role.rb +3 -0
  29. data/lib/super_auth/active_record.rb +30 -2
  30. data/lib/super_auth/authorization.rb +63 -10
  31. data/lib/super_auth/edge.rb +73 -18
  32. data/lib/super_auth/editor/index.html +16 -10
  33. data/lib/super_auth/editor/seed.rb +11 -5
  34. data/lib/super_auth/editor.rb +35 -11
  35. data/lib/super_auth/nestable.rb +105 -4
  36. data/lib/super_auth/reach.rb +88 -0
  37. data/lib/super_auth/resource.rb +71 -0
  38. data/lib/super_auth/rls.rb +576 -44
  39. data/lib/super_auth/tree_guard.rb +115 -0
  40. data/lib/super_auth/version.rb +1 -1
  41. data/lib/super_auth.rb +55 -2
  42. 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) | No |
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. They can link to your app's models.
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
- # Named resource
257
+ # A named resource with nothing behind it
254
258
  staging = SuperAuth::Resource.create(name: "staging")
255
259
 
256
- # Linked to an ActiveRecord model
257
- posts = SuperAuth::Resource.create(
258
- name: "posts",
259
- external_id: nil,
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
- 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`).
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 your app's Post model
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: "posts",
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); 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.
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", external_type: "API")
581
- dashboard = SuperAuth::Resource.create(name: "dashboard", external_type: "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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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
- t.timestamps default: -> { "CURRENT_TIMESTAMP" }
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