super_auth 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +132 -49
  6. data/USAGE.md +98 -31
  7. data/config/routes.rb +9 -71
  8. data/db/migrate/10_add_super_auth_label_to_resources.rb +13 -0
  9. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  10. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  11. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  12. data/db/seeds/sample_data.rb +1 -0
  13. data/exe/super_auth-editor +9 -0
  14. data/lib/generators/super_auth/install/templates/README +18 -11
  15. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  16. data/lib/super_auth/active_record/authorization.rb +7 -0
  17. data/lib/super_auth/active_record/by_current_user.rb +1 -1
  18. data/lib/super_auth/active_record/resource.rb +45 -0
  19. data/lib/super_auth/active_record/user.rb +3 -1
  20. data/lib/super_auth/authorization.rb +24 -0
  21. data/lib/super_auth/edge.rb +54 -18
  22. data/lib/super_auth/editor/cli.rb +91 -0
  23. data/lib/super_auth/editor/index.html +430 -0
  24. data/lib/super_auth/editor/seed.rb +176 -0
  25. data/lib/super_auth/editor.rb +288 -0
  26. data/lib/super_auth/nestable.rb +16 -3
  27. data/lib/super_auth/railtie.rb +9 -2
  28. data/lib/super_auth/resource.rb +57 -0
  29. data/lib/super_auth/rls.rb +164 -34
  30. data/lib/super_auth/user.rb +3 -1
  31. data/lib/super_auth/version.rb +1 -1
  32. data/lib/super_auth.rb +73 -5
  33. data/lib/tasks/super_auth_tasks.rake +28 -0
  34. metadata +13 -8
  35. data/VISUALIZATION.md +0 -58
  36. data/app/controllers/super_auth/graph_controller.rb +0 -654
  37. data/app/views/super_auth/graph/index.html.erb +0 -1408
  38. data/super_auth.gemspec +0 -35
  39. data/visualization.html +0 -747
data/USAGE.md CHANGED
@@ -62,7 +62,7 @@ rails railties:install: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 adds migration 11, `parent_id` on resources).
66
66
 
67
67
  **Step 4.** Set the current user in your controller:
68
68
 
@@ -95,14 +95,18 @@ Post.all # only posts the current user can access
95
95
  Post.where(published: true) # scoped AND filtered by authorization
96
96
  ```
97
97
 
98
- **Step 6 (optional).** Mount the visualization engine:
98
+ **Step 6 (optional).** Mount the engine, which serves the graph editor. It has no
99
+ authentication of its own, so mount it inside yours:
99
100
 
100
101
  ```ruby
101
102
  # config/routes.rb
102
- mount SuperAuth::Engine => '/super_auth'
103
+ authenticate :admin do # Devise; or a constraints block
104
+ mount SuperAuth::Engine => '/super_auth'
105
+ end
103
106
  ```
104
107
 
105
- Then visit `http://localhost:3000/super_auth/visualization` to see your authorization graph.
108
+ Then open `http://localhost:3000/super_auth` to edit the graph. Edits take effect at
109
+ runtime after **Recompile** (or `SuperAuth::ActiveRecord::Authorization.compile!`).
106
110
 
107
111
  ### Standalone Setup (without Rails)
108
112
 
@@ -130,7 +134,7 @@ SuperAuth models authorization as a graph with 5 entity types:
130
134
  | **Group** | Organizational units (teams, departments, etc) | Yes (nested) |
131
135
  | **Role** | Job titles or permission sets | Yes (nested) |
132
136
  | **Permission** | Actions (read, write, deploy, etc) | No |
133
- | **Resource** | Things being protected (files, APIs, records) | No |
137
+ | **Resource** | Things being protected (files, APIs, records) | Yes (nested) |
134
138
 
135
139
  **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.
136
140
 
@@ -241,22 +245,77 @@ write_perm = SuperAuth::Permission.create(name: "write")
241
245
  deploy_perm = SuperAuth::Permission.create(name: "deploy")
242
246
  ```
243
247
 
244
- ### Resources
248
+ ### Resources (hierarchical)
245
249
 
246
- Resources represent what you are protecting. They can link to your app's models.
250
+ 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.
247
251
 
248
252
  ```ruby
249
- # Named resource
253
+ # A named resource with nothing behind it
250
254
  staging = SuperAuth::Resource.create(name: "staging")
251
255
 
252
- # Linked to an ActiveRecord model
253
- posts = SuperAuth::Resource.create(
254
- name: "posts",
255
- external_id: nil,
256
- external_type: "Post"
256
+ # A container, and a record of your app registered under it
257
+ reports = SuperAuth::Resource.create(name: "reports")
258
+ q3 = SuperAuth::Resource.create(
259
+ name: "Q3 report",
260
+ external_type: "Post",
261
+ external_id: post.id,
262
+ parent: reports
257
263
  )
264
+
265
+ # A grant on the container reaches q3, and every node registered under
266
+ # reports later, as of the next compile!
267
+ SuperAuth::Edge.create(permission: read_perm, resource: reports)
258
268
  ```
259
269
 
270
+ Navigate the tree the same way as groups:
271
+
272
+ ```ruby
273
+ SuperAuth::Resource.roots # nodes with no parent
274
+ reports.children_dataset.all # => [q3]
275
+ q3.parent # => reports
276
+ ```
277
+
278
+ 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
+
280
+ #### Deprecated: type-level (wildcard) nodes
281
+
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.
283
+
284
+ ```ruby
285
+ # Deprecated: every Post, present and future
286
+ posts = SuperAuth::Resource.create(name: "posts", external_type: "Post")
287
+ ```
288
+
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`.
290
+
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.
292
+
293
+ ##### Migrating a wildcard to a container
294
+
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:
296
+
297
+ ```ruby
298
+ wildcard = SuperAuth::Resource.where(external_type: "Post", external_id: nil).first
299
+
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
307
+ end
308
+
309
+ if SuperAuth::RLS.installed?
310
+ SuperAuth.as(SuperAuth::User.system, &migrate)
311
+ else
312
+ SuperAuth.db.transaction(&migrate)
313
+ end
314
+ SuperAuth::Authorization.compile! # SuperAuth::ActiveRecord::Authorization.compile! in Rails
315
+ ```
316
+
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
+
260
319
  ## Drawing Edges
261
320
 
262
321
  Edges are the core of SuperAuth. Each edge connects exactly two entities.
@@ -307,6 +366,8 @@ SuperAuth automatically evaluates 5 pathing strategies and unions the results. Y
307
366
 
308
367
  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*.
309
368
 
369
+ 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)).
370
+
310
371
  ```ruby
311
372
  # Bethany is in Company (the root group)
312
373
  SuperAuth::Edge.create(user: bethany, group: company)
@@ -345,6 +406,8 @@ auth[:resource_id] # Integer
345
406
  auth[:resource_name] # "staging"
346
407
  ```
347
408
 
409
+ `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)).
410
+
348
411
  ### Filter by user
349
412
 
350
413
  ```ruby
@@ -457,6 +520,8 @@ Post::PostPublishPermission.find(id) # needs a "Post::PostPublishPermission" gra
457
520
 
458
521
  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!`.
459
522
 
523
+ 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
+
460
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`).
461
526
 
462
527
  ### Linking to your app's models
@@ -471,14 +536,15 @@ sa_user = SuperAuth::User.create(
471
536
  external_type: "User"
472
537
  )
473
538
 
474
- # Link a SuperAuth resource to your app's Post model
539
+ # Link a SuperAuth resource to one Post
475
540
  sa_resource = SuperAuth::Resource.create(
476
- name: "posts",
477
- external_type: "Post"
541
+ name: post.title,
542
+ external_type: "Post",
543
+ external_id: post.id
478
544
  )
479
545
  ```
480
546
 
481
- 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.
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).
482
548
 
483
549
  ### ActiveRecord models
484
550
 
@@ -533,25 +599,25 @@ deployers.map { |a| a[:user_name] }.uniq
533
599
 
534
600
  ## Visualization
535
601
 
536
- SuperAuth includes an interactive graph visualization UI. After mounting the engine:
602
+ The graph editor shows the whole graph as five boxes (groups, roles, users,
603
+ permissions, resources), drawing groups, roles and resources as trees; click any record
604
+ to trace what it can reach and what reaches it, connect records to draw edges, create
605
+ records (including a resource container under a chosen parent), delete records and
606
+ edges, and recompile. The editor makes containers; your application registers records
607
+ under them. See the README's "Graph editor" section for the full description.
537
608
 
538
- ```ruby
539
- # config/routes.rb
540
- mount SuperAuth::Engine => '/super_auth'
541
- ```
609
+ - Rails: mount the engine inside your own authentication (Step 6 above) and open
610
+ `http://localhost:3000/super_auth`.
611
+ - Anywhere else: `super_auth-editor` serves it on loopback against
612
+ `SUPER_AUTH_DATABASE_URL`, or `require "super_auth/editor"` and mount
613
+ `SuperAuth::Editor` in any Rack app, inside your authentication.
542
614
 
543
- Load sample data (optional):
615
+ Load sample data (optional, Rails):
544
616
 
545
617
  ```bash
546
618
  rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
547
619
  ```
548
620
 
549
- Visit `http://localhost:3000/super_auth/visualization` to see:
550
-
551
- - Color-coded nodes for each entity type
552
- - Interactive path finding
553
- - Real-time authorization queries
554
-
555
621
  ## Full Example
556
622
 
557
623
  Here's a complete example modeling a company with departments, roles, and resources:
@@ -575,8 +641,8 @@ write = SuperAuth::Permission.create(name: "write")
575
641
  deploy = SuperAuth::Permission.create(name: "deploy")
576
642
 
577
643
  # Resources
578
- api = SuperAuth::Resource.create(name: "api", external_type: "API")
579
- dashboard = SuperAuth::Resource.create(name: "dashboard", external_type: "Dashboard")
644
+ api = SuperAuth::Resource.create(name: "api")
645
+ dashboard = SuperAuth::Resource.create(name: "dashboard")
580
646
  prod_db = SuperAuth::Resource.create(name: "production_db")
581
647
 
582
648
  # Users
@@ -629,6 +695,7 @@ auths = SuperAuth::Edge.authorizations.all
629
695
  | `SuperAuth.current_user` | Get the current user |
630
696
  | `SuperAuth.install_migrations` | Create all `super_auth_*` tables |
631
697
  | `SuperAuth.uninstall_migrations`| Drop all `super_auth_*` tables |
698
+ | `SuperAuth.deprecator` | Where deprecation warnings go; `silenced = true` quiets them |
632
699
 
633
700
  ### Environment Variables
634
701
 
data/config/routes.rb CHANGED
@@ -1,73 +1,11 @@
1
+ require "super_auth/editor"
2
+
3
+ # The engine serves the graph editor at its mount point. Mount it inside your
4
+ # own authentication: it has none of its own.
5
+ #
6
+ # authenticate :admin do
7
+ # mount SuperAuth::Engine => "/super_auth"
8
+ # end
1
9
  SuperAuth::Engine.routes.draw do
2
- # Main graph visualization interface
3
- get '/', to: 'graph#index', as: :root
4
- get '/graph', to: 'graph#index'
5
-
6
- # Graph data API
7
- get '/graph/data', to: 'graph#data'
8
- get '/graph/orphaned', to: 'graph#orphaned'
9
- post '/graph/compile_authorizations', to: 'graph#compile_authorizations'
10
-
11
- # Authorization check
12
- get '/graph/authorize', to: 'graph#authorize'
13
-
14
- # Legacy visualization endpoint
15
- get '/visualization', to: 'graph#visualization'
16
-
17
- # CRUD operations for graph entities
18
- scope :graph do
19
- resources :users, only: [:create, :destroy], controller: 'graph' do
20
- collection do
21
- post '/', action: :create_user
22
- end
23
- member do
24
- delete '/', action: :delete_user
25
- end
26
- end
27
-
28
- resources :groups, only: [:create, :destroy], controller: 'graph' do
29
- collection do
30
- post '/', action: :create_group
31
- end
32
- member do
33
- delete '/', action: :delete_group
34
- end
35
- end
36
-
37
- resources :roles, only: [:create, :destroy], controller: 'graph' do
38
- collection do
39
- post '/', action: :create_role
40
- end
41
- member do
42
- delete '/', action: :delete_role
43
- end
44
- end
45
-
46
- resources :permissions, only: [:create, :destroy], controller: 'graph' do
47
- collection do
48
- post '/', action: :create_permission
49
- end
50
- member do
51
- delete '/', action: :delete_permission
52
- end
53
- end
54
-
55
- resources :graph_resources, only: [:create, :destroy], controller: 'graph', path: 'resources' do
56
- collection do
57
- post '/', action: :create_resource
58
- end
59
- member do
60
- delete '/', action: :delete_resource
61
- end
62
- end
63
-
64
- resources :edges, only: [:create, :destroy], controller: 'graph' do
65
- collection do
66
- post '/', action: :create_edge
67
- end
68
- member do
69
- delete '/', action: :delete_edge
70
- end
71
- end
72
- end
10
+ mount SuperAuth::Editor, at: "/"
73
11
  end
@@ -0,0 +1,13 @@
1
+ Sequel.migration do
2
+ up do
3
+ alter_table(:super_auth_resources) do
4
+ add_column :super_auth_label, String
5
+ end
6
+ end
7
+
8
+ down do
9
+ alter_table(:super_auth_resources) do
10
+ drop_column :super_auth_label
11
+ end
12
+ end
13
+ end
@@ -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,5 @@
1
+ class AddSuperAuthLabelToSuperAuthResources < ActiveRecord::Migration[7.0]
2
+ def change
3
+ add_column :super_auth_resources, :super_auth_label, :string
4
+ end
5
+ 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
@@ -29,6 +29,7 @@ puts "Clearing existing data..."
29
29
  Edge.delete_all
30
30
  Group.update_all(parent_id: nil)
31
31
  Role.update_all(parent_id: nil)
32
+ Resource.update_all(parent_id: nil)
32
33
  User.delete_all
33
34
  Group.delete_all
34
35
  Role.delete_all
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # Serves the super_auth graph editor on loopback. See `super_auth-editor --help`.
3
+ require "super_auth/editor/cli"
4
+
5
+ begin
6
+ SuperAuth::Editor::CLI.run(ARGV)
7
+ rescue SuperAuth::Error => e
8
+ abort "super_auth-editor: #{e.message}"
9
+ end
@@ -4,26 +4,33 @@ SuperAuth initializer created at config/initializers/super_auth.rb
4
4
 
5
5
  Next steps:
6
6
 
7
- 1. Run migrations to create the database tables:
7
+ 1. Copy the engine's migrations into your app and run them:
8
8
 
9
- SuperAuth.install_migrations
9
+ rails railties:install:migrations
10
+ rails db:migrate
10
11
 
11
- You can run this in the Rails console or add it to a rake task.
12
+ (the engine does not run its migrations by itself; repeat both after an
13
+ upgrade that ships a new one)
12
14
 
13
- 2. Mount the engine in config/routes.rb:
15
+ 2. Mount the engine in config/routes.rb, inside your own authentication.
16
+ It serves the graph editor, which has no authentication of its own:
14
17
 
15
- mount SuperAuth::Engine => '/super_auth'
18
+ authenticate :admin do
19
+ mount SuperAuth::Engine => '/super_auth'
20
+ end
16
21
 
17
- 3. (Optional) Load sample data to see the visualization in action:
22
+ 3. (Optional) Load sample data to have a graph to look at:
18
23
 
19
24
  rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
20
25
 
21
- 4. Start your Rails server and visit:
26
+ 4. Start your Rails server and open the editor:
22
27
 
23
- http://localhost:3000/super_auth/visualization
28
+ http://localhost:3000/super_auth
24
29
 
25
- For more information, see:
26
- - VISUALIZATION.md for full documentation
27
- - README.md for usage examples
30
+ Edits change the graph; runtime access comes from the compiled table,
31
+ so press Recompile (or run SuperAuth::ActiveRecord::Authorization.compile!)
32
+ after editing.
33
+
34
+ For more information, see README.md ("Graph editor") and USAGE.md.
28
35
 
29
36
  ===============================================================================
@@ -3,6 +3,8 @@ class EnableSuperAuthRls < ActiveRecord::Migration[<%= ActiveRecord::Migration.c
3
3
  <% model_names.each do |model| -%>
4
4
  SuperAuth::RLS.enable(:<%= model.tableize.tr('/', '_') %>, resource_type: "<%= model.camelize %>")
5
5
  <% end -%>
6
+ # Roles allowed to bypass the policies (migrations, seeds, admin jobs):
7
+ # SuperAuth::RLS.grant_system(:app_admin)
6
8
  end
7
9
 
8
10
  def down
@@ -9,11 +9,18 @@ class SuperAuth::ActiveRecord::Authorization < ActiveRecord::Base
9
9
  end
10
10
 
11
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.
12
14
  def compile!
13
15
  transaction do
16
+ # Sequel runs on this transaction's connection (sequel-activerecord_connection),
17
+ # so the JIT switch lands in it; see SuperAuth::Authorization.compile!.
18
+ SuperAuth.db.run "SET LOCAL jit = off" if SuperAuth.db.database_type == :postgres
19
+ SuperAuth::Resource.assert_compilable!
14
20
  delete_all
15
21
  from_graph.each { |auth| create!(auth.attributes.except("id")) }
16
22
  end
23
+ SuperAuth::Resource.warn_deprecated_wildcards
17
24
  count
18
25
  end
19
26
  end
@@ -30,7 +30,7 @@ module SuperAuth::ActiveRecord::ByCurrentUser
30
30
  self
31
31
  else
32
32
  user_where =
33
- if SuperAuth.current_user.is_a?(SuperAuth::ActiveRecord::User)
33
+ if SuperAuth.internal_user?(SuperAuth.current_user)
34
34
  { user_id: SuperAuth.current_user.id }
35
35
  else
36
36
  { user_external_id: SuperAuth.current_user.id, user_external_type: SuperAuth.current_user.class.name }
@@ -1,4 +1,49 @@
1
1
  class SuperAuth::ActiveRecord::Resource < ActiveRecord::Base
2
2
  self.table_name = 'super_auth_resources'
3
3
  belongs_to :external, polymorphic: true, optional: true
4
+ # optional: is load-bearing: Rails hosts set belongs_to_required_by_default,
5
+ # the gem's own suite does not, so a missing one passes CI and fails the
6
+ # host on every root node.
7
+ belongs_to :parent, class_name: 'SuperAuth::ActiveRecord::Resource', optional: true
8
+
9
+ # `super_auth_label` is a stored snapshot of the application record's human
10
+ # name, so the editor can render "Gulf War presumptive" instead of
11
+ # Claim#3a00b6fa and `super_auth-editor` can do it against a bare
12
+ # SUPER_AUTH_DATABASE_URL with no application loaded. It carries the prefix
13
+ # for the same reason the opt-in method on the host's model does: `label` is
14
+ # a name applications want for themselves.
15
+ #
16
+ # Deriving it here means a host that already syncs its resources gets labels
17
+ # with no extra wiring; renames still need refresh_label!, since they do not
18
+ # write this row.
19
+ before_save :set_label, if: :external_id?
20
+
21
+ # Re-derive the label after the application record is renamed. Hosts call it
22
+ # from whatever already syncs the node; super_auth:labels:backfill calls it
23
+ # for every row.
24
+ def refresh_label!
25
+ derived = derived_label
26
+ update_column(:super_auth_label, derived) unless derived.nil?
27
+ end
28
+
29
+ private
30
+
31
+ def set_label
32
+ derived = derived_label
33
+ self.super_auth_label = derived unless derived.nil?
34
+ end
35
+
36
+ # ponytail: a nil derivation never overwrites a stored label, in either
37
+ # path, and never fails the save. Three things derive nil and none of them
38
+ # means "this record has no name": RLS makes the application record
39
+ # unreadable without an asserted identity, external_type is a plain string
40
+ # that can name a class this process has not loaded, and id-less rows — a
41
+ # container, or a deprecated wildcard — have no record to name at all.
42
+ # Writing nil for any of them would turn "this label is stale" into data,
43
+ # which is the failure this column exists to avoid.
44
+ def derived_label
45
+ SuperAuth.label_for(external)
46
+ rescue NameError
47
+ nil
48
+ end
4
49
  end
@@ -5,7 +5,9 @@ class SuperAuth::ActiveRecord::User < ActiveRecord::Base
5
5
 
6
6
  def model_name = ActiveModel::Name.new(:user)
7
7
 
8
- def system? = self.class.system == self
8
+ # A read: runtime roles only get SELECT on this table. `.system` creates
9
+ # the row when missing and belongs to migrations, seeds and consoles.
10
+ def system? = self.class.find_by(name: "system") == self
9
11
  def self.system = find_or_create_by(name: "system")
10
12
 
11
13
  has_many :edges, class_name: 'SuperAuth::ActiveRecord::Edge'
@@ -1,2 +1,26 @@
1
1
  class SuperAuth::Authorization < Sequel::Model(:super_auth_authorizations)
2
+ # Clears and repopulates the compiled table from the current graph, inside
3
+ # one transaction, and returns the row count. Row by row, like
4
+ # SuperAuth::ActiveRecord::Authorization.compile!; a single INSERT ... SELECT
5
+ # is a separate change. Runtime enforcement (ByCurrentUser, the RLS policies)
6
+ # reads only this table, so every edit to the graph is inert until this runs.
7
+ # The wildcard guard runs first, before the delete, so a refused compile
8
+ # leaves the previous rows in place rather than an empty table; the
9
+ # deprecation notice comes after the commit, for a compile that happened.
10
+ #
11
+ # Postgres JIT-compiles the union's expressions on every run: 539 LLVM
12
+ # functions, 1.6-2.2s of optimisation and emission for a query that then
13
+ # executes in milliseconds. SET LOCAL scopes the switch to this transaction,
14
+ # so nothing leaks to the pooled connection.
15
+ def self.compile!
16
+ count = db.transaction do
17
+ db.run "SET LOCAL jit = off" if db.database_type == :postgres
18
+ SuperAuth::Resource.assert_compilable!
19
+ dataset.delete
20
+ SuperAuth::Edge.authorizations.each { |row| dataset.insert(row) }
21
+ dataset.count
22
+ end
23
+ SuperAuth::Resource.warn_deprecated_wildcards
24
+ count
25
+ end
2
26
  end