feat: add write lock for AuthUser migration preparation

This commit implements a temporary write lock on the AuthUser model to
prepare for consolidating the openneo_id database into the main database.

Changes:
- AuthUser: Added before_save/before_destroy callbacks that raise
  TemporarilyReadOnly exception to prevent any writes
- AuthUser: Override update_tracked_fields! to silently skip Devise login
  tracking (prevents crashes during login while maintaining read access)
- ApplicationController: Added rescue_from handler for TemporarilyReadOnly
  that redirects to root with a friendly maintenance message
- Migration: Created CopyAuthUsersTableToMainDatabase to copy
  openneo_id.users → openneo_impress.auth_users (preserves all IDs)

This allows us to run the data copy migration in production while:
- Keeping existing users able to log in and use the site
- Blocking new registrations, settings changes, and NeoPass connections
- Losing some login tracking data (acceptable tradeoff)

Next step: Deploy this, then run the migration in production while the
table is stable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Emi Matchu 2025-11-02 06:49:47 +00:00
parent 77872311e6
commit 604a8667cf
4 changed files with 111 additions and 1 deletions

View file

@ -27,6 +27,10 @@ class ApplicationController < ActionController::Base
rescue_from ActiveRecord::ConnectionTimeoutError, with: :on_db_timeout
# TEMPORARY: Rescue handler for database migration write lock
# This catches attempts to modify AuthUser records during the migration.
rescue_from AuthUser::TemporarilyReadOnly, with: :on_auth_user_read_only
def authenticate_user!
redirect_to(new_auth_user_session_path) unless user_signed_in?
end
@ -73,6 +77,11 @@ class ApplicationController < ActionController::Base
status: :service_unavailable
end
def on_auth_user_read_only
flash[:alert] = "Account changes are temporarily unavailable during maintenance. Please try again shortly."
redirect_to root_path
end
def redirect_back!(default=:back)
redirect_to(params[:return_to] || default)
end

View file

@ -1,10 +1,39 @@
class AuthUser < AuthRecord
self.table_name = 'users'
# TEMPORARY: Write lock for database migration
# During the migration to consolidate databases, we need to prevent writes to
# the AuthUser table while keeping reads (like login) working. This will be
# removed in the next phase of the migration.
class TemporarilyReadOnly < StandardError; end
# Block all save attempts via before_save callback (more reliable than overriding methods)
before_save :prevent_writes_during_migration
before_destroy :prevent_writes_during_migration
def prevent_writes_during_migration
raise TemporarilyReadOnly, "Account changes temporarily unavailable during maintenance"
end
devise :database_authenticatable, :encryptable, :registerable, :validatable,
:rememberable, :trackable, :recoverable, :omniauthable,
omniauth_providers: [:neopass]
# Disable Devise's trackable feature during migration to prevent login tracking updates
# This means we'll lose some login tracking data during the migration window, but that's
# acceptable compared to breaking logins entirely.
def update_tracked_fields!(request)
# Normally this would update sign_in_count, current_sign_in_at, etc.
# During migration, we just skip it silently.
end
# Disable Devise's rememberable feature during migration to prevent logout from crashing
# This means remember_created_at won't be cleared on logout, but that's acceptable.
def forget_me!
# Normally this would update remember_created_at to nil.
# During migration, we just skip it silently.
end
validates :name, presence: true, uniqueness: {case_sensitive: false},
length: {maximum: 30}

View file

@ -0,0 +1,45 @@
class CopyAuthUsersTableToMainDatabase < ActiveRecord::Migration[8.0]
def up
# Create auth_users table in openneo_impress with same structure as openneo_id.users
# This preserves all IDs, data, and constraints from the source table.
create_table "auth_users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false
t.string "encrypted_password", limit: 64
t.string "email", limit: 50
t.string "password_salt", limit: 32
t.string "reset_password_token"
t.integer "sign_in_count", default: 0
t.datetime "current_sign_in_at", precision: nil
t.datetime "last_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.integer "failed_attempts", default: 0
t.string "unlock_token"
t.datetime "locked_at", precision: nil
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.datetime "reset_password_sent_at", precision: nil
t.datetime "remember_created_at"
t.string "provider"
t.string "uid"
t.string "neopass_email"
end
# Add indexes (matching openneo_id.users schema)
add_index "auth_users", ["email"], name: "index_auth_users_on_email", unique: true
add_index "auth_users", ["provider", "uid"], name: "index_auth_users_on_provider_and_uid", unique: true
add_index "auth_users", ["reset_password_token"], name: "index_auth_users_on_reset_password_token", unique: true
add_index "auth_users", ["unlock_token"], name: "index_auth_users_on_unlock_token", unique: true
# Copy all data from openneo_id.users to openneo_impress.auth_users
# This preserves all IDs so that User.remote_id continues to reference the correct AuthUser
execute <<-SQL
INSERT INTO openneo_impress.auth_users
SELECT * FROM openneo_id.users
SQL
end
def down
drop_table "auth_users"
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
ActiveRecord::Schema[8.0].define(version: 2025_11_02_064247) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "species_id", null: false
t.integer "color_id", null: false
@ -32,6 +32,33 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
t.string "secret", limit: 64, null: false
end
create_table "auth_users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false
t.string "encrypted_password", limit: 64
t.string "email", limit: 50
t.string "password_salt", limit: 32
t.string "reset_password_token"
t.integer "sign_in_count", default: 0
t.datetime "current_sign_in_at", precision: nil
t.datetime "last_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.integer "failed_attempts", default: 0
t.string "unlock_token"
t.datetime "locked_at", precision: nil
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.datetime "reset_password_sent_at", precision: nil
t.datetime "remember_created_at"
t.string "provider"
t.string "uid"
t.string "neopass_email"
t.index ["email"], name: "index_auth_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_auth_users_on_provider_and_uid", unique: true
t.index ["reset_password_token"], name: "index_auth_users_on_reset_password_token", unique: true
t.index ["unlock_token"], name: "index_auth_users_on_unlock_token", unique: true
end
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "progress", default: 0, null: false
t.integer "goal", null: false