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:createas a prefix also matches a host'sdb:create_tenant, anddb:migratematchesdb:migrate_legacy_users. Prefix matching turned an allow list back into thedb: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:seedrepairs 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
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 |