Module: SuperAuth

Defined in:
lib/super_auth/reach.rb,
lib/super_auth.rb,
lib/super_auth/rls.rb,
lib/super_auth/editor.rb,
lib/super_auth/railtie.rb,
lib/super_auth/version.rb,
lib/super_auth/editor/cli.rb,
lib/super_auth/tree_guard.rb,
lib/super_auth/editor/seed.rb,
lib/generators/super_auth/rls/rls_generator.rb,
lib/generators/super_auth/install/install_generator.rb

Overview

The reach map: which authorization rows admit a row of a protected table.

Both layers take the same two keywords, resource_type: and parent:, and both ask the same question of every row: does the current user hold a compiled authorization that reaches it? A row is reached through its own id (a per-record grant, or a type-level grant on the class's own type) or through a column holding another record's id (a parent grant: the row's tenancy, read off the row itself). The reach map is that question in one shape, an ordered Hash from column to the types whose rows admit through it, with :id always the first step:

SuperAuth::Reach.normalize(
resource_type: "Claim",
parent: { column: :organization_id, resource_type: %w[Organization::Member Organization::Admin] })
# => { id: ["Claim"], organization_id: ["Organization::Member", "Organization::Admin"] }

The RLS policy and the ByCurrentUser scope each emit one step per entry, and both build from this map rather than from the raw keywords so they cannot drift: an argument shape one layer accepted and the other rejected, or a column one saw and the other did not, is a row the ORM shows and the database hides, or the reverse, which is the dangerous direction. Every entry is a list because RLS must never be narrower than any tier's ORM scope over the same table: a table whose readers key on Organization::Member and whose writers on Organization::CaseWriter names both under the column, so the holder of one without the other is still admitted at the database.

:id is refused as a parent column since it is the per-record step, already declared by resource_type:. A column declared twice is refused because the second entry would silently shadow the first; every type a column admits goes in one list.

Defined Under Namespace

Modules: ActiveRecord, Generators, Nestable, RLS, Reach, TreeGuard Classes: Authorization, Edge, Editor, Engine, Error, Group, Permission, Railtie, Resource, Role, User

Constant Summary collapse

VERSION =
"0.9.0"

Class Method Summary collapse

Class Method Details

.as(user, db: SuperAuth.db, **options) ⇒ Object

Run the block as user in both layers: SuperAuth.current_user, which the ByCurrentUser scope reads, and the database identity the RLS policies read (see SuperAuth::RLS.as). Both are restored on the way out, whether the block returns, raises, or was nested inside another as. Passing nil runs the block with no user in either layer. Postgres only, since the database half is. Keyword options (auto_savepoint:, ...) go to SuperAuth::RLS.as. current_user is assigned inside the transaction, after the database identity, so an application that hooks the writer to re-assert does so on the connection that holds the transaction.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/super_auth.rb', line 145

def self.as(user, db: SuperAuth.db, **options)
  previous = current_user
  unless rls?(db)
    # No policy on this database reads the identity, so there is nothing to
    # assert and no reason to open a transaction for it — the ORM scope
    # reads current_user and nothing else. This is the shape of a host that
    # has not turned RLS on, and of every host on SQLite or MySQL, where
    # RLS.as raises. Turning RLS on later needs no application change: the
    # same call starts asserting both layers. `options` is dropped rather
    # than passed on: every one of them is a Sequel transaction option
    # (auto_savepoint:, isolation:, ...) and there is no transaction here to
    # give them to. Anything that ever means something outside one has to be
    # handled before this return, not added to the splat.
    self.current_user = user
    return yield
  end
  SuperAuth::RLS.as(user, db: db, **options) do
    self.current_user = user
    yield
  end
ensure
  self.current_user = previous
end

.current_userObject



196
197
198
# File 'lib/super_auth.rb', line 196

def self.current_user
  Thread.current[:super_auth_current_user]
end

.current_user=(user) ⇒ Object



192
193
194
# File 'lib/super_auth.rb', line 192

def self.current_user=(user)
  Thread.current[:super_auth_current_user] = user
end

.dbObject



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/super_auth.rb', line 200

def self.db
  return @db if !@db.nil?

  if !Gem::Specification.find_all_by_name("activerecord").empty?
    require "active_record"
    extensions = Gem::Specification.find_all_by_name("sequel-activerecord_connection").any? ? { extensions: :activerecord_connection } : {}

    if extensions.empty?
      warn "[SuperAuth] WARNING: Found ActiveRecord but could not find the gem 'sequel-activerecord_connection' installed. SuperAuth may not always work as expected."
    end

    begin
      ::ActiveRecord::Base.establish_connection
    rescue ActiveRecord::AdapterNotSpecified
      if defined?(Rails) && !Rails.env.local?
        raise Error, "SuperAuth could not find a database configuration. " \
          "Please configure ActiveRecord or set SUPER_AUTH_DATABASE_URL."
      end
      warn "[SuperAuth] WARNING: No database configured. Falling back to in-memory SQLite. " \
        "All authorization data will be lost on restart."
      ::ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
    end

    # Walk the ancestor chain so adapter subclasses (e.g. PostGIS, Makara)
    # are recognized as their parent adapter type.
    adapter_ancestors = ::ActiveRecord::Base.adapter_class.ancestors.map(&:to_s)
    if adapter_ancestors.include?("ActiveRecord::ConnectionAdapters::SQLite3Adapter")
      SuperAuth.db = Sequel.sqlite(**extensions)
    elsif adapter_ancestors.include?("ActiveRecord::ConnectionAdapters::PostgreSQLAdapter")
      SuperAuth.db = Sequel.postgres(**extensions)
    elsif adapter_ancestors.include?("ActiveRecord::ConnectionAdapters::Mysql2Adapter")
      SuperAuth.db = Sequel.mysql2(**extensions)
    else
      warn "[SuperAuth] WARNING: Unknown adapter: #{::ActiveRecord::Base.adapter_class}"
    end
  else
    logger =
    if defined?(Rails) && ENV["SUPER_AUTH_LOG_LEVEL"] == "debug"
      { logger: Rails.logger }
    elsif ENV["SUPER_AUTH_LOG_LEVEL"] == "debug"
      require "logger"
      { logger: Logger.new(STDOUT) }
    else
      {} # no logger
    end

    if !ENV['SUPER_AUTH_DATABASE_URL'].nil? && !ENV['SUPER_AUTH_DATABASE_URL'].empty?
      SuperAuth.db = Sequel.connect(ENV['SUPER_AUTH_DATABASE_URL'], **logger)
    else
      if defined?(Rails) && !Rails.env.local?
        raise Error, "SuperAuth could not find a database configuration. " \
          "Please set SUPER_AUTH_DATABASE_URL or configure ActiveRecord."
      end
      warn "[SuperAuth] WARNING: SUPER_AUTH_DATABASE_URL not set. Falling back to in-memory SQLite. " \
        "All authorization data will be lost on restart."
      SuperAuth.db = Sequel.sqlite(**logger)
    end
  end
end

.db=(db) ⇒ Object

Models already loaded follow the new database; see refresh_model_schemas.



261
262
263
264
265
# File 'lib/super_auth.rb', line 261

def self.db=(db)
  @db = db
  @rls = nil
  refresh_model_schemas if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
end

.external_id_typeObject

Column type for the external id columns (users.external_id, resources.external_id and their copies on authorizations). Set it to your application's primary key type (:bigint, :uuid, :string, ...) BEFORE running the super_auth migrations — the columns are then created with the matching type and every comparison against your tables' pks is natively typed, with no casting anywhere. Default :string.



33
34
35
# File 'lib/super_auth.rb', line 33

def self.external_id_type
  @external_id_type || :string
end

.external_id_type=(type) ⇒ Object



37
38
39
# File 'lib/super_auth.rb', line 37

def self.external_id_type=(type)
  @external_id_type = type
end

.install_migrationsObject



78
79
80
81
82
83
84
# File 'lib/super_auth.rb', line 78

def self.install_migrations
  Sequel.extension :migration
  require "pathname"
  path = Pathname.new(__FILE__).parent.parent.join("db", "migrate")
  Sequel::Migrator.run(SuperAuth.db, path)
  refresh_model_schemas
end

.internal_user?(user) ⇒ Boolean

Both user models are internal: their id is the user_id that the policies and ByCurrentUser match on. Anything else is an application object, matched by id and class name.

Returns:

  • (Boolean)


187
188
189
190
# File 'lib/super_auth.rb', line 187

def self.internal_user?(user)
  (defined?(SuperAuth::ActiveRecord::User) && user.is_a?(SuperAuth::ActiveRecord::User)) ||
    (defined?(SuperAuth::User) && user.is_a?(SuperAuth::User))
end

.label_for(record) ⇒ Object

The human name of the application record behind a node, by convention rather than configuration: a model says it explicitly with super_auth_label, otherwise name then title are tried, and a model with none of them has no label. This is also the name of the column the derivation is stored in, since label is a name applications want for themselves. Deliberately never to_s — the label sits where the editor otherwise renders Type#id, and "#Claim:0x000055…" is worse than the id it would replace.



55
56
57
58
59
60
61
62
# File 'lib/super_auth.rb', line 55

def self.label_for(record)
  %i[super_auth_label name title].each do |method|
    next unless record.respond_to?(method)
    value = record.public_send(method)
    return value.to_s unless value.nil? || value.to_s.empty?
  end
  nil
end

.loadObject



64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/super_auth.rb', line 64

def self.load
  require "super_auth/authorization"
  require "super_auth/edge"
  require "super_auth/nestable"
  require "super_auth/group"
  require "super_auth/permission"
  require "super_auth/railtie"
  require "super_auth/resource"
  require "super_auth/rls"
  require "super_auth/role"
  require "super_auth/user"
  require "super_auth/active_record" if defined?(ActiveRecord::Base)
end

.missing_user_behaviorObject

Controls behavior when SuperAuth.current_user is blank in ByCurrentUser scope. :none (default) — returns an empty result set silently :raise — raises SuperAuth::Error



16
17
18
# File 'lib/super_auth.rb', line 16

def self.missing_user_behavior
  @missing_user_behavior || :none
end

.missing_user_behavior=(behavior) ⇒ Object



20
21
22
23
24
25
# File 'lib/super_auth.rb', line 20

def self.missing_user_behavior=(behavior)
  unless %i[none raise].include?(behavior)
    raise ArgumentError, "missing_user_behavior must be :none or :raise, got #{behavior.inspect}"
  end
  @missing_user_behavior = behavior
end

.refresh_model_schemasObject

Both ORMs cache column types per model class; after (re)installing the migrations those caches can describe a previous schema (e.g. a different external_id_type) and silently miscast assigned values. The Sequel models are also rebound to SuperAuth.db: a host requires them before it has connected anything, so Sequel binds them to whatever Sequel::Model.db is at that moment (a mock, in a Rails boot), and a class left on that binding answers db.database_type wrong and runs its queries nowhere. Rebinding here, and from SuperAuth.db=, keeps Model.db equal to SuperAuth.db for every model, so no host needs to do it by hand.



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/super_auth.rb', line 95

def self.refresh_model_schemas
  models = %w[User Group Permission Role Resource Edge Authorization]
  if defined?(SuperAuth::ActiveRecord::User)
    models.each do |name|
      SuperAuth::ActiveRecord.const_get(name).reset_column_information
    end
  end
  if defined?(SuperAuth::User) && SuperAuth::User.respond_to?(:set_dataset)
    models.each do |name|
      model = SuperAuth.const_get(name)
      if @db.nil? || model.db.equal?(@db)
        model.set_dataset(model.dataset)
      else
        # A dataset on the target database is the one way to move a model
        # that already has one: Sequel refuses Model.db= after that point.
        # A host may set SuperAuth.db before its migrations have run, so a
        # missing table moves the binding and leaves the columns to the
        # next refresh, which install_migrations makes.
        begin
          model.set_dataset(@db[model.table_name])
        rescue Sequel::DatabaseError
          nil
        end
      end
    end
  end
end

.rls!Object



180
181
182
# File 'lib/super_auth.rb', line 180

def self.rls!
  @rls = nil
end

.rls?(db = SuperAuth.db) ⇒ Boolean

Whether db carries the RLS functions — the question as asks on every block, so the answer is memoised for SuperAuth.db: it is a catalogue round trip, and only a migration changes it. RLS.enable, which is what creates them, clears it; so does SuperAuth.db=. Call rls! after installing them some other way in a live process.

Returns:

  • (Boolean)


174
175
176
177
178
# File 'lib/super_auth.rb', line 174

def self.rls?(db = SuperAuth.db)
  return SuperAuth::RLS.installed?(db: db) unless db.equal?(@db)
  @rls = SuperAuth::RLS.installed?(db: db) if @rls.nil?
  @rls
end

.sequel_external_id_typeObject

Sequel migrations take the Ruby String class for varchar; anything else passes through as the literal database type name.



43
44
45
# File 'lib/super_auth.rb', line 43

def self.sequel_external_id_type
  external_id_type == :string ? String : external_id_type
end

.setup {|_self| ... } ⇒ Object

Yields:

  • (_self)

Yield Parameters:

  • _self (SuperAuth)

    the object that the method was called on



9
10
11
# File 'lib/super_auth.rb', line 9

def self.setup
  yield self if block_given?
end

.uninstall_migrationsObject



123
124
125
126
127
128
129
130
131
132
133
134
# File 'lib/super_auth.rb', line 123

def self.uninstall_migrations
  require "sequel"
  Sequel.extension :migration
  require "pathname"

  path = Pathname.new(__FILE__).parent.parent.join("db", "migrate")
  db = SuperAuth.db

  Sequel::Migrator.run(db, path, target: 0)
rescue => e
  raise Error, "Failed to uninstall migrations: #{e.message}"
end