super_auth 0.3.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +78 -0
  3. data/Gemfile +5 -0
  4. data/Gemfile.lock +8 -1
  5. data/README.md +286 -22
  6. data/USAGE.md +41 -16
  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/1_users.rb +1 -1
  10. data/db/migrate/5_resources.rb +1 -1
  11. data/db/migrate/7_authorization.rb +2 -2
  12. data/db/migrate/8_add_indexes_to_edges.rb +7 -0
  13. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +1 -1
  14. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +1 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +2 -2
  16. data/db/migrate_activerecord/20250101000010_add_super_auth_label_to_super_auth_resources.rb +5 -0
  17. data/exe/super_auth-editor +9 -0
  18. data/lib/generators/super_auth/install/templates/README +15 -10
  19. data/lib/generators/super_auth/install/templates/super_auth.rb +16 -5
  20. data/lib/generators/super_auth/rls/rls_generator.rb +22 -0
  21. data/lib/generators/super_auth/rls/templates/migration.rb.erb +15 -0
  22. data/lib/super_auth/active_record/by_current_user.rb +25 -5
  23. data/lib/super_auth/active_record/resource.rb +41 -0
  24. data/lib/super_auth/active_record/user.rb +3 -1
  25. data/lib/super_auth/authorization.rb +12 -0
  26. data/lib/super_auth/edge.rb +65 -100
  27. data/lib/super_auth/editor/cli.rb +91 -0
  28. data/lib/super_auth/editor/index.html +423 -0
  29. data/lib/super_auth/editor/seed.rb +170 -0
  30. data/lib/super_auth/editor.rb +273 -0
  31. data/lib/super_auth/nestable.rb +41 -3
  32. data/lib/super_auth/railtie.rb +0 -2
  33. data/lib/super_auth/rls.rb +256 -0
  34. data/lib/super_auth/user.rb +3 -1
  35. data/lib/super_auth/version.rb +1 -1
  36. data/lib/super_auth.rb +85 -0
  37. data/lib/tasks/super_auth_tasks.rake +28 -0
  38. metadata +15 -9
  39. data/VISUALIZATION.md +0 -58
  40. data/app/controllers/super_auth/graph_controller.rb +0 -654
  41. data/app/views/super_auth/graph/index.html.erb +0 -1408
  42. data/super_auth.gemspec +0 -35
  43. data/visualization.html +0 -747
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
@@ -2,7 +2,7 @@ Sequel.migration do
2
2
  up do
3
3
  create_table(:super_auth_users) do
4
4
  primary_key :id
5
- String :external_id # , null: false
5
+ column :external_id, SuperAuth.sequel_external_id_type # , null: false
6
6
  String :external_type # , null: false
7
7
  String :name
8
8
  DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
@@ -3,7 +3,7 @@ Sequel.migration do
3
3
  create_table(:super_auth_resources) do
4
4
  primary_key :id
5
5
  String :name
6
- String :external_id # , null: false
6
+ column :external_id, SuperAuth.sequel_external_id_type # , null: false
7
7
  String :external_type # , null: false
8
8
  DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
9
9
  DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
@@ -3,7 +3,7 @@ Sequel.migration do
3
3
  create_table(:super_auth_authorizations) do
4
4
  Integer :user_id, null: true
5
5
  String :user_name, null: true
6
- String :user_external_id, null: true
6
+ column :user_external_id, SuperAuth.sequel_external_id_type, null: true
7
7
  String :user_external_type, null: true
8
8
  DateTime :user_created_at, null: true
9
9
  DateTime :user_updated_at, null: true
@@ -28,7 +28,7 @@ Sequel.migration do
28
28
  DateTime :permission_updated_at, null: true
29
29
  Integer :resource_id, null: true
30
30
  String :resource_name, null: true
31
- String :resource_external_id, null: true
31
+ column :resource_external_id, SuperAuth.sequel_external_id_type, null: true
32
32
  String :resource_external_type, null: true
33
33
  DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
34
34
  DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
@@ -1,5 +1,10 @@
1
1
  Sequel.migration do
2
+ # InnoDB already creates an index for every foreign key column, and will not
3
+ # drop one a constraint depends on, so on MySQL these indexes are redundant
4
+ # and their removal would fail. Skip them there.
2
5
  up do
6
+ next if [:mysql, :mysql2].include?(database_type)
7
+
3
8
  add_index :super_auth_edges, :user_id
4
9
  add_index :super_auth_edges, :group_id
5
10
  add_index :super_auth_edges, :role_id
@@ -8,6 +13,8 @@ Sequel.migration do
8
13
  end
9
14
 
10
15
  down do
16
+ next if [:mysql, :mysql2].include?(database_type)
17
+
11
18
  drop_index :super_auth_edges, :user_id
12
19
  drop_index :super_auth_edges, :group_id
13
20
  drop_index :super_auth_edges, :role_id
@@ -1,7 +1,7 @@
1
1
  class CreateSuperAuthUsers < ActiveRecord::Migration[7.0]
2
2
  def change
3
3
  create_table :super_auth_users do |t|
4
- t.string :external_id
4
+ t.column :external_id, SuperAuth.external_id_type
5
5
  t.string :external_type
6
6
  t.string :name
7
7
  t.timestamps default: -> { "CURRENT_TIMESTAMP" }
@@ -2,7 +2,7 @@ class CreateSuperAuthResources < ActiveRecord::Migration[7.0]
2
2
  def change
3
3
  create_table :super_auth_resources do |t|
4
4
  t.string :name
5
- t.string :external_id
5
+ t.column :external_id, SuperAuth.external_id_type
6
6
  t.string :external_type
7
7
  t.timestamps default: -> { "CURRENT_TIMESTAMP" }
8
8
  end
@@ -3,7 +3,7 @@ class CreateSuperAuthAuthorizations < ActiveRecord::Migration[7.0]
3
3
  create_table :super_auth_authorizations do |t|
4
4
  t.integer :user_id
5
5
  t.string :user_name
6
- t.string :user_external_id
6
+ t.column :user_external_id, SuperAuth.external_id_type
7
7
  t.string :user_external_type
8
8
  t.datetime :user_created_at
9
9
  t.datetime :user_updated_at
@@ -32,7 +32,7 @@ class CreateSuperAuthAuthorizations < ActiveRecord::Migration[7.0]
32
32
 
33
33
  t.integer :resource_id
34
34
  t.string :resource_name
35
- t.string :resource_external_id
35
+ t.column :resource_external_id, SuperAuth.external_id_type
36
36
  t.string :resource_external_type
37
37
 
38
38
  t.timestamps default: -> { "CURRENT_TIMESTAMP" }
@@ -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
+ #!/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
@@ -6,24 +6,29 @@ Next steps:
6
6
 
7
7
  1. Run migrations to create the database tables:
8
8
 
9
- SuperAuth.install_migrations
9
+ rails db:migrate
10
10
 
11
- You can run this in the Rails console or add it to a rake task.
11
+ (the engine adds its ActiveRecord migrations to your app)
12
12
 
13
- 2. Mount the engine in config/routes.rb:
13
+ 2. Mount the engine in config/routes.rb, inside your own authentication.
14
+ It serves the graph editor, which has no authentication of its own:
14
15
 
15
- mount SuperAuth::Engine => '/super_auth'
16
+ authenticate :admin do
17
+ mount SuperAuth::Engine => '/super_auth'
18
+ end
16
19
 
17
- 3. (Optional) Load sample data to see the visualization in action:
20
+ 3. (Optional) Load sample data to have a graph to look at:
18
21
 
19
22
  rails runner "load File.join(SuperAuth::Engine.root, 'db/seeds/sample_data.rb')"
20
23
 
21
- 4. Start your Rails server and visit:
24
+ 4. Start your Rails server and open the editor:
22
25
 
23
- http://localhost:3000/super_auth/visualization
26
+ http://localhost:3000/super_auth
24
27
 
25
- For more information, see:
26
- - VISUALIZATION.md for full documentation
27
- - README.md for usage examples
28
+ Edits change the graph; runtime access comes from the compiled table,
29
+ so press Recompile (or run SuperAuth::ActiveRecord::Authorization.compile!)
30
+ after editing.
31
+
32
+ For more information, see README.md ("Graph editor") and USAGE.md.
28
33
 
29
34
  ===============================================================================
@@ -2,8 +2,19 @@
2
2
  # The SuperAuth Railtie automatically connects to your database and loads all
3
3
  # models on boot. Use this file for any additional configuration.
4
4
  #
5
- # SuperAuth.setup do |config|
6
- # # Raise an error when a query runs without a current user set.
7
- # # Default is :none (returns empty results silently).
8
- # # config.missing_user_behavior = :raise
9
- # end
5
+ SuperAuth.setup do |config|
6
+ # Column type for external id columns, created when the migrations run.
7
+ # Match your application's primary key type (:bigint, :uuid, :string, ...)
8
+ # so comparisons against your tables' pks are natively typed. Rails
9
+ # defaults to bigint pks. Must be set before running super_auth migrations.
10
+ config.external_id_type = :bigint
11
+
12
+ # Raise an error when a query runs without a current user set.
13
+ # Default is :none (returns empty results silently).
14
+ # config.missing_user_behavior = :raise
15
+
16
+ # Postgres row-level security: enable it per table with
17
+ # rails generate super_auth:rls Model ...
18
+ # then wrap request work in SuperAuth.as(current_user) { ... } wherever
19
+ # database-level enforcement should apply.
20
+ end
@@ -0,0 +1,22 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/active_record'
3
+
4
+ module SuperAuth
5
+ module Generators
6
+ class RlsGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ # The model class names typed on the command line, verbatim:
12
+ # `rails g super_auth:rls Document Invoice` => ["Document", "Invoice"]
13
+ argument :model_names, type: :array, banner: "Model Model ..."
14
+
15
+ desc "Creates a migration enabling Postgres row-level security for the given models"
16
+
17
+ def create_migration_file
18
+ migration_template 'migration.rb.erb', 'db/migrate/enable_super_auth_rls.rb'
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ class EnableSuperAuthRls < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def up
3
+ <% model_names.each do |model| -%>
4
+ SuperAuth::RLS.enable(:<%= model.tableize.tr('/', '_') %>, resource_type: "<%= model.camelize %>")
5
+ <% end -%>
6
+ # Roles allowed to bypass the policies (migrations, seeds, admin jobs):
7
+ # SuperAuth::RLS.grant_system(:app_admin)
8
+ end
9
+
10
+ def down
11
+ <% model_names.each do |model| -%>
12
+ SuperAuth::RLS.disable(:<%= model.tableize.tr('/', '_') %>)
13
+ <% end -%>
14
+ end
15
+ end
@@ -1,4 +1,24 @@
1
1
  module SuperAuth::ActiveRecord::ByCurrentUser
2
+ # Records are filtered to those the current user holds an authorization for,
3
+ # keyed by the querying class's name. Because a subclass is its own resource
4
+ # type, privileged methods can be placed on a subclass whose access must be
5
+ # approved explicitly — a grant on the base class does not flow down:
6
+ #
7
+ # class Resource < ApplicationRecord
8
+ # super_auth
9
+ #
10
+ # class ResourceRestartPermission < Resource
11
+ # def restart!
12
+ # # dangerous restart operation
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # Resource::ResourceRestartPermission shares the base class's table and rows,
18
+ # but loading it requires an authorization whose resource_external_type is
19
+ # "Resource::ResourceRestartPermission" (edges to a SuperAuth::Resource
20
+ # registered with that external_type). If you can't load the object, you
21
+ # can't call the method.
2
22
  def self.included(base)
3
23
  base.send(:default_scope, **{all_queries: true}) do
4
24
  if SuperAuth.current_user.blank?
@@ -10,7 +30,7 @@ module SuperAuth::ActiveRecord::ByCurrentUser
10
30
  self
11
31
  else
12
32
  user_where =
13
- if SuperAuth.current_user.is_a?(SuperAuth::ActiveRecord::User)
33
+ if SuperAuth.internal_user?(SuperAuth.current_user)
14
34
  { user_id: SuperAuth.current_user.id }
15
35
  else
16
36
  { user_external_id: SuperAuth.current_user.id, user_external_type: SuperAuth.current_user.class.name }
@@ -26,7 +46,10 @@ module SuperAuth::ActiveRecord::ByCurrentUser
26
46
  if type_level.exists?
27
47
  self
28
48
  else
29
- # Per-record authorization: filter to specific records the user can access.
49
+ # Per-record authorization: filter to specific records the user can
50
+ # access. No type handling here: the external id columns are created
51
+ # with the app's pk type (SuperAuth.external_id_type at install
52
+ # time), so the comparison is natively typed.
30
53
  where(
31
54
  id: SuperAuth::ActiveRecord::Authorization
32
55
  .where(**user_where, resource_external_type: resource_type)
@@ -36,7 +59,4 @@ module SuperAuth::ActiveRecord::ByCurrentUser
36
59
  end
37
60
  end
38
61
  end
39
-
40
- module ClassMethods
41
- end
42
62
  end
@@ -1,4 +1,45 @@
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
+
5
+ # `super_auth_label` is a stored snapshot of the application record's human
6
+ # name, so the editor can render "Gulf War presumptive" instead of
7
+ # Claim#3a00b6fa and `super_auth-editor` can do it against a bare
8
+ # SUPER_AUTH_DATABASE_URL with no application loaded. It carries the prefix
9
+ # for the same reason the opt-in method on the host's model does: `label` is
10
+ # a name applications want for themselves.
11
+ #
12
+ # Deriving it here means a host that already syncs its resources gets labels
13
+ # with no extra wiring; renames still need refresh_label!, since they do not
14
+ # write this row.
15
+ before_save :set_label, if: :external_id?
16
+
17
+ # Re-derive the label after the application record is renamed. Hosts call it
18
+ # from whatever already syncs the node; super_auth:labels:backfill calls it
19
+ # for every row.
20
+ def refresh_label!
21
+ derived = derived_label
22
+ update_column(:super_auth_label, derived) unless derived.nil?
23
+ end
24
+
25
+ private
26
+
27
+ def set_label
28
+ derived = derived_label
29
+ self.super_auth_label = derived unless derived.nil?
30
+ end
31
+
32
+ # ponytail: a nil derivation never overwrites a stored label, in either
33
+ # path, and never fails the save. Three things derive nil and none of them
34
+ # means "this record has no name": RLS makes the application record
35
+ # unreadable without an asserted identity, external_type is a plain string
36
+ # that can name a class this process has not loaded, and type-level rows
37
+ # (external_id IS NULL) have no record to name at all. Writing nil for any
38
+ # of them would turn "this label is stale" into data, which is the failure
39
+ # this column exists to avoid.
40
+ def derived_label
41
+ SuperAuth.label_for(external)
42
+ rescue NameError
43
+ nil
44
+ end
4
45
  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,14 @@
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
+ def self.compile!
8
+ db.transaction do
9
+ dataset.delete
10
+ SuperAuth::Edge.authorizations.each { |row| dataset.insert(row) }
11
+ dataset.count
12
+ end
13
+ end
2
14
  end