super_auth 0.8.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/Gemfile.lock +1 -1
  4. data/README.md +409 -28
  5. data/USAGE.md +66 -25
  6. data/db/migrate/12_add_resource_indexes.rb +116 -0
  7. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  8. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  9. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  10. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  11. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  12. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  13. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  14. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  15. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  16. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  17. data/lib/generators/super_auth/install/templates/README +4 -2
  18. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  19. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  20. data/lib/super_auth/active_record/authorization.rb +11 -7
  21. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  22. data/lib/super_auth/active_record/group.rb +3 -0
  23. data/lib/super_auth/active_record/nested.rb +43 -0
  24. data/lib/super_auth/active_record/resource.rb +21 -1
  25. data/lib/super_auth/active_record/role.rb +3 -0
  26. data/lib/super_auth/active_record.rb +30 -2
  27. data/lib/super_auth/authorization.rb +62 -21
  28. data/lib/super_auth/edge.rb +26 -7
  29. data/lib/super_auth/editor/index.html +3 -4
  30. data/lib/super_auth/editor.rb +12 -3
  31. data/lib/super_auth/nestable.rb +89 -1
  32. data/lib/super_auth/railtie.rb +0 -9
  33. data/lib/super_auth/reach.rb +88 -0
  34. data/lib/super_auth/resource.rb +41 -27
  35. data/lib/super_auth/rls.rb +576 -44
  36. data/lib/super_auth/tree_guard.rb +115 -0
  37. data/lib/super_auth/version.rb +1 -1
  38. data/lib/super_auth.rb +55 -33
  39. metadata +8 -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. 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 adds migration 11, `parent_id` on resources).
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
 
@@ -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.
@@ -277,35 +281,51 @@ q3.parent # => reports
277
281
 
278
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.
279
283
 
280
- #### Deprecated: type-level (wildcard) nodes
284
+ #### Type-level grants
281
285
 
282
- A node with an `external_type` and no `external_id` is a type-level, or wildcard, node: 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 `resource_external_id IS NULL OR` clause does the same in the database.
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.
283
287
 
284
288
  ```ruby
285
- # Deprecated: every Post, present and future
289
+ # Every Post, present and future
286
290
  posts = SuperAuth::Resource.create(name: "posts", external_type: "Post")
287
291
  ```
288
292
 
289
- Wildcard nodes are deprecated as of 0.8.0 and still work: `compile!` warns once per compile naming the ones that exist (silence it with `SuperAuth.deprecator.silenced = true`, or in Rails through `config.active_support.deprecation`), and no removal version is promised. What `compile!` refuses, with a `SuperAuth::Error` naming the node ids, is a wildcard with a parent or children: nested in the tree it would reach every record of its type through its ancestors' grants. A wildcard stays at the root with no children, or gets an `external_id`.
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`.
290
294
 
291
- Under Postgres row-level security a wildcard remains the only way to authorize INSERT this release. The policy is `FOR ALL` with `USING` reused as `WITH CHECK`, and a per-record row can only match an id that has already been registered and compiled. A container is not a replacement for it there: it loses INSERT, it needs a node saved and a full recompile for every new record, and saving that node needs a role that can write the gem's tables, which `enable` grants `SELECT` on only. The successor is a grant on a parent record (`SuperAuth::RLS.enable(:documents, resource_type: "Document", parent: { column: :folder_id, resource_type: "Folder" })`, with a `ByCurrentUser` mirror), planned for the next release.
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.
292
296
 
293
- ##### Migrating a wildcard to a container
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.
294
298
 
295
- Where the type is not under row-level security, or INSERT is not needed, a wildcard becomes a container plus one node per record. Move the edges off the wildcard before destroying it, so the grants survive, then compile. `Post` is a `super_auth` model, so its default scope hides every row from a process with no current user — the loop reads through `unscoped`; where RLS is installed the database enforces the same rule, so the work runs as the system user:
299
+ #### Parent-record grants (tenancy from a column)
296
300
 
297
- ```ruby
298
- wildcard = SuperAuth::Resource.where(external_type: "Post", external_id: nil).first
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:
299
302
 
300
- migrate = proc do
301
- container = SuperAuth::Resource.create(name: "posts") # untyped: a container
302
- Post.unscoped.find_each do |post| # ByCurrentUser hides every row without a current user
303
- SuperAuth::Resource.create(name: post.title, external_type: "Post", external_id: post.id, parent: container)
304
- end
305
- SuperAuth::Edge.where(resource_id: wildcard.id).update(resource_id: container.id) # update_all on the ActiveRecord twin
306
- wildcard.destroy
303
+ ```ruby
304
+ class Post < ApplicationRecord
305
+ super_auth parent: { column: :blog_id, resource_type: ["Blog::Member"] }
307
306
  end
308
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
+
309
329
  if SuperAuth::RLS.installed?
310
330
  SuperAuth.as(SuperAuth::User.system, &migrate)
311
331
  else
@@ -314,8 +334,6 @@ end
314
334
  SuperAuth::Authorization.compile! # SuperAuth::ActiveRecord::Authorization.compile! in Rails
315
335
  ```
316
336
 
317
- Under row-level security this loses INSERT on `posts` until the parent-record grant exists, and a `Post` created afterwards needs its own node and a recompile before anyone sees it.
318
-
319
337
  ## Drawing Edges
320
338
 
321
339
  Edges are the core of SuperAuth. Each edge connects exactly two entities.
@@ -522,7 +540,9 @@ Approve the subclass like any other resource — register a `SuperAuth::Resource
522
540
 
523
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.
524
542
 
525
- 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`).
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).
526
546
 
527
547
  ### Linking to your app's models
528
548
 
@@ -536,15 +556,25 @@ sa_user = SuperAuth::User.create(
536
556
  external_type: "User"
537
557
  )
538
558
 
539
- # Link a SuperAuth resource to one Post
559
+ # Link a SuperAuth resource to one Post, in a container beside its siblings
560
+ reports = SuperAuth::Resource.create(name: "reports")
540
561
  sa_resource = SuperAuth::Resource.create(
541
562
  name: post.title,
542
563
  external_type: "Post",
543
- external_id: post.id
564
+ external_id: post.id,
565
+ parent: reports
544
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)
545
575
  ```
546
576
 
547
- 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 deprecated wildcard shape described under [Resources](#resources-hierarchical).
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.
548
578
 
549
579
  ### ActiveRecord models
550
580
 
@@ -597,6 +627,18 @@ deployers = SuperAuth::Edge.authorizations.all.select { |a|
597
627
  deployers.map { |a| a[:user_name] }.uniq
598
628
  ```
599
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
+
600
642
  ## Visualization
601
643
 
602
644
  The graph editor shows the whole graph as five boxes (groups, roles, users,
@@ -695,7 +737,6 @@ auths = SuperAuth::Edge.authorizations.all
695
737
  | `SuperAuth.current_user` | Get the current user |
696
738
  | `SuperAuth.install_migrations` | Create all `super_auth_*` tables |
697
739
  | `SuperAuth.uninstall_migrations`| Drop all `super_auth_*` tables |
698
- | `SuperAuth.deprecator` | Where deprecation warnings go; `silenced = true` quiets them |
699
740
 
700
741
  ### Environment Variables
701
742
 
@@ -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,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
@@ -0,0 +1,15 @@
1
+ class AddResourceTreeGuardToSuperAuthResources < ActiveRecord::Migration[7.0]
2
+ # Mirrors db/migrate/13_add_resource_tree_guard.rb, which says why: a
3
+ # parent_id cycle silently widens grants, the models and compile! refuse
4
+ # it, and this trigger refuses it for writes that go around them. Postgres
5
+ # only; a no-op elsewhere. The SQL lives in SuperAuth::TreeGuard so a host
6
+ # can install it from its test setup as well: db/schema.rb cannot carry a
7
+ # trigger, so a test database built from it has none.
8
+ def up
9
+ SuperAuth::TreeGuard.install(db: SuperAuth.db)
10
+ end
11
+
12
+ def down
13
+ SuperAuth::TreeGuard.remove(db: SuperAuth.db)
14
+ end
15
+ end
@@ -6,11 +6,13 @@ Next steps:
6
6
 
7
7
  1. Copy the engine's migrations into your app and run them:
8
8
 
9
- rails railties:install:migrations
9
+ rails super_auth:install:migrations
10
10
  rails db:migrate
11
11
 
12
12
  (the engine does not run its migrations by itself; repeat both after an
13
- upgrade that ships a new one)
13
+ upgrade that ships a new one, and re-run SuperAuth::RLS.enable for any
14
+ table under row-level security, since a policy already in the database
15
+ does not change with the gem — SuperAuth::RLS.stale lists the ones behind)
14
16
 
15
17
  2. Mount the engine in config/routes.rb, inside your own authentication.
16
18
  It serves the graph editor, which has no authentication of its own:
@@ -13,8 +13,11 @@ SuperAuth.setup do |config|
13
13
  # Default is :none (returns empty results silently).
14
14
  # config.missing_user_behavior = :raise
15
15
 
16
- # Postgres row-level security: enable it per table with
16
+ # Wrap request work in SuperAuth.as(current_user) { ... } — in an
17
+ # around_action, and around jobs — so the ByCurrentUser scope has a user.
18
+ #
19
+ # Postgres row-level security is optional and off until you ask for it:
17
20
  # rails generate super_auth:rls Model ...
18
- # then wrap request work in SuperAuth.as(current_user) { ... } wherever
19
- # database-level enforcement should apply.
21
+ # Once that migration has run, the same SuperAuth.as call also asserts the
22
+ # identity the policies read, so turning it on costs no application change.
20
23
  end
@@ -2,6 +2,8 @@ class EnableSuperAuthRls < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
2
2
  def up
3
3
  <% model_names.each do |model| -%>
4
4
  SuperAuth::RLS.enable(:<%= model.tableize.tr('/', '_') %>, resource_type: "<%= model.camelize %>")
5
+ # Tenancy, not capability: a parent grant admits every row whose column holds a granted record's id, so list every type that may touch the row at all and let the ORM decide who writes.
6
+ # parent: { column: :organization_id, resource_type: ["Organization::Member"] }
5
7
  <% end -%>
6
8
  # Roles allowed to bypass the policies (migrations, seeds, admin jobs):
7
9
  # SuperAuth::RLS.grant_system(:app_admin)
@@ -8,20 +8,24 @@ class SuperAuth::ActiveRecord::Authorization < ActiveRecord::Base
8
8
  from("(#{SuperAuth::Edge.authorizations.sql}) as super_auth_authorizations".squish)
9
9
  end
10
10
 
11
- # Clears and repopulates the authorizations table from the current graph.
12
- # The wildcard guard runs before the delete, so a refused compile leaves
13
- # the previous rows in place; the deprecation notice follows the commit.
11
+ # Clears and repopulates the authorizations table from the current graph
12
+ # with one INSERT ... SELECT on this connection, and returns the row
13
+ # count; see SuperAuth::Authorization.compile!. The guards run before the
14
+ # delete, so a refused compile leaves the previous rows in place.
14
15
  def compile!
15
16
  transaction do
16
17
  # Sequel runs on this transaction's connection (sequel-activerecord_connection),
17
18
  # so the JIT switch lands in it; see SuperAuth::Authorization.compile!.
18
19
  SuperAuth.db.run "SET LOCAL jit = off" if SuperAuth.db.database_type == :postgres
19
- SuperAuth::Resource.assert_compilable!
20
+ SuperAuth::Authorization.assert_compilable!
20
21
  delete_all
21
- from_graph.each { |auth| create!(auth.attributes.except("id")) }
22
+ connection.execute(
23
+ SuperAuth.db[:super_auth_authorizations].insert_sql(
24
+ SuperAuth::Edge::AUTHORIZATION_COLUMNS, SuperAuth::Authorization.compile_source
25
+ )
26
+ )
27
+ count
22
28
  end
23
- SuperAuth::Resource.warn_deprecated_wildcards
24
- count
25
29
  end
26
30
  end
27
31
  end