Module: CurrentScope::SchemaGuard

Defined in:
lib/current_scope/schema_guard.rb

Overview

The #151 boot guard: does this database actually have the grant-column shape the fix requires, and may THIS command boot without it?

Its own file rather than more of Engine, matching SodPreflight and ParentChain — Engine states when a check runs, the check itself lives here.

#151 is fixed by a MIGRATION, and a gem upgrade does not run one. A host that bundles 0.5 and deploys without applying it keeps integer id columns and keeps the full escalation, silently, because every code path behaves correctly against whatever schema it is given. So check the schema itself and refuse to serve. This is the one check that cannot be a validation: the damage is in the column, not in the next write.

Constant Summary collapse

BOOT_EXEMPT_TASKS =

Rake tasks that may BOOT even against an unrepaired schema.

The check raises from after_initialize, which every Rails command runs — including db:migrate, the command its own error message tells the host to run. Without an exemption an upgrading host is stuck: the app refuses to boot and the repair refuses to run, for the same reason. assets: is the same trap one step less obvious — a deploy pipeline that precompiles before migrating dies before it ever reaches the fix.

EXACT NAMES, not prefixes, and that distinction is load-bearing: matching db:create as a prefix also matches a host's db:create_tenant, and db:migrate matches db:migrate_legacy_users. Prefix matching turned an allow list back into the db: free-for-all it replaced. Namespaces that legitimately have children are listed separately, ending in a colon.

Anything that serves traffic or runs host code — server, console, runner — is deliberately absent. db:setup, db:reset and db:prepare are present despite seeding: they are how a host REBUILDS a database, and refusing them would leave a broken schema with no way to replace it.

%w[
  db:create db:drop db:migrate db:rollback db:version db:prepare db:setup
  db:reset db:abort_if_pending_migrations db:_dump
  current_scope:install current_scope:repair_schema
].freeze
BOOT_EXEMPT_NAMESPACES =

Namespaces whose children are all schema tooling (db:migrate:up, db:schema:load, assets:precompile, …).

%w[
  db:migrate: db:schema: db:structure: db:test: db:environment:
  current_scope:install: assets:
].freeze
BOOT_REFUSED_TASKS =

…minus these, which the lists above would otherwise cover. A bare db:seed repairs nothing and runs host code, and seeds routinely create grants through this engine — on the pre-migration schema, exactly the writes that collapse two subjects into one. Prefixes here on purpose: db:seed:replant (which Rails ships) and a host's own db:seed_users are both host code, and over-refusing is the safe direction for a deny list.

%w[db:seed db:fixtures:].freeze

Class Method Summary collapse

Class Method Details

.check!(allow_database_task: true) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/current_scope/schema_guard.rb', line 15

def self.check!(allow_database_task: true)
  return if allow_database_task && running_a_database_task?
  # Escape hatch for tooling that must BOOT in order to migrate — our own
  # bin/db does exactly that, because schema.rb cannot carry a MySQL
  # collation. Deliberately an explicit opt-out and not a config flag: a host
  # that sets this in production has chosen to run without the check.
  return if ENV["CURRENT_SCOPE_SKIP_SCHEMA_CHECK"] == "1"

  # Strict "1" on purpose — this switches OFF a security control, so it
  # should be awkward to trip. But silence would be worse than strictness: a
  # host who writes `=true` (which the gem's other env opt-out does accept)
  # would otherwise believe the check was off while it was still armed.
  if ENV.key?("CURRENT_SCOPE_SKIP_SCHEMA_CHECK")
    Rails.logger&.warn(
      "[CurrentScope] CURRENT_SCOPE_SKIP_SCHEMA_CHECK is set to " \
      "#{ENV['CURRENT_SCOPE_SKIP_SCHEMA_CHECK'].inspect} and was IGNORED — the only " \
      "value that disables the #151 schema check is the string \"1\"."
    )
  end

  # BOTH halves of every grant predicate, not just the ids. The migration
  # widens the id columns and then re-collates the type columns, and MySQL
  # auto-commits each statement — so a migration that dies between the two
  # leaves binary ids beside case-insensitive types, permanently. Checking
  # only the ids blesses exactly that state, and a grant on `Widget#5` then
  # matches a check for `WIDGET#5`. Same escalation, other column.
  {
    CurrentScope::RoleAssignment => { ids: %w[subject_id], types: %w[subject_type] },
    CurrentScope::ScopedRoleAssignment => {
      ids: %w[subject_id resource_id], types: %w[subject_type resource_type]
    }
  }.each do |model, groups|
    next unless model.table_exists?

    groups[:ids].each { |column| check_id_column!(model, column) }
    # Type columns are varchar already; only their collation can be wrong.
    groups[:types].each { |column| check_collation!(model, column) } if mysql?
  end
rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
  # There is genuinely no database yet (a fresh checkout running db:create,
  # a build step with no server). Nothing to judge, so stay quiet.
  #
  # Narrow ON PURPOSE. Rescuing ActiveRecordError broadly here would turn any
  # transient error into a silent all-clear, which is a security guard
  # failing open. Anything else propagates.
  nil
end

.running_a_database_task?Boolean

Returns:

  • (Boolean)


202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/current_scope/schema_guard.rb', line 202

def self.running_a_database_task?
  return false unless defined?(Rake) && Rake.respond_to?(:application)

  # `app:` is how an engine's host tasks are namespaced from inside the
  # engine (rails/tasks/engine.rake), so strip it once here instead of
  # spelling every entry twice in both lists.
  tasks = Rake.application.top_level_tasks.map { |task| task.delete_prefix("app:") }
  return false if tasks.any? { |task| task.start_with?(*BOOT_REFUSED_TASKS) }

  tasks.any? do |task|
    BOOT_EXEMPT_TASKS.include?(task) || task.start_with?(*BOOT_EXEMPT_NAMESPACES)
  end
rescue StandardError
  # No rake application in scope (a server or console): not a database task.
  false
end