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
-
.as(user, db: SuperAuth.db, **options) ⇒ Object
Run the block as
userin both layers: SuperAuth.current_user, which the ByCurrentUser scope reads, and the database identity the RLS policies read (see SuperAuth::RLS.as). - .current_user ⇒ Object
- .current_user=(user) ⇒ Object
- .db ⇒ Object
-
.db=(db) ⇒ Object
Models already loaded follow the new database; see refresh_model_schemas.
-
.external_id_type ⇒ Object
Column type for the external id columns (users.external_id, resources.external_id and their copies on authorizations).
- .external_id_type=(type) ⇒ Object
- .install_migrations ⇒ Object
-
.internal_user?(user) ⇒ Boolean
Both user models are internal: their id is the user_id that the policies and ByCurrentUser match on.
-
.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, otherwisenamethentitleare tried, and a model with none of them has no label. - .load ⇒ Object
-
.missing_user_behavior ⇒ Object
Controls behavior when SuperAuth.current_user is blank in ByCurrentUser scope.
- .missing_user_behavior=(behavior) ⇒ Object
-
.refresh_model_schemas ⇒ Object
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.
- .rls! ⇒ Object
-
.rls?(db = SuperAuth.db) ⇒ Boolean
Whether
dbcarries the RLS functions — the questionasasks on every block, so the answer is memoised for SuperAuth.db: it is a catalogue round trip, and only a migration changes it. -
.sequel_external_id_type ⇒ Object
Sequel migrations take the Ruby String class for varchar; anything else passes through as the literal database type name.
- .setup {|_self| ... } ⇒ Object
- .uninstall_migrations ⇒ Object
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, **) 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, **) do self.current_user = user yield end ensure self.current_user = previous end |
.current_user ⇒ Object
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 |
.db ⇒ Object
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_type ⇒ Object
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_migrations ⇒ Object
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.
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 |
.load ⇒ Object
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_behavior ⇒ Object
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_schemas ⇒ Object
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.
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_type ⇒ Object
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
9 10 11 |
# File 'lib/super_auth.rb', line 9 def self.setup yield self if block_given? end |
.uninstall_migrations ⇒ Object
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.}" end |