Deprecate users.static_object_token column (Step 1 of 2)
What does this MR do and why?
Completes the first phase (ignore/deprecation) step of the two-release removal process for the users.static_object_token column, targeting milestone 19.5. The actual column drop will follow in 19.6.
This MR:
- Sets the encryption strategy to
:required(all reads/writes now usestatic_object_token_encryptedonly) - Adds
ignore_column :static_object_tokento prevent application code from referencing the plaintext column - Drops the remaining UNIQUE index
index_users_on_static_object_tokenvia post-deployment migration
Context: The plaintext static_object_token column has been superseded by the encrypted static_object_token_encrypted column since the token encryption migration in #348163 (closed) (!76684 (merged)). All token values were migrated to the encrypted column in that MR. This MR completes the deprecation phase; the column itself will be dropped in 19.6.
Related:
- Issue: #348426
- Predecessor (encryption migration): #348163 (closed) / !76684 (merged)
- Original 2022 attempt (abandoned as Draft): !84746 (closed)
How to set up and validate locally
1. Verify migration runs cleanly
# Run the post-deployment migration
bundle exec rails db:migrate:up:main VERSION=20260919043508
# Verify the index is gone
bundle exec rails runner "
conn = ActiveRecord::Base.connection
exists = conn.index_name_exists?(:users, :index_users_on_static_object_token)
puts exists ? 'FAIL: Index still exists' : 'PASS: Index removed'
"2. Verify reversibility
# Roll back the migration
bundle exec rails db:migrate:down:main VERSION=20260919043508
# Verify the index is back
bundle exec rails runner "
conn = ActiveRecord::Base.connection
exists = conn.index_name_exists?(:users, :index_users_on_static_object_token)
puts exists ? 'PASS: Index restored' : 'FAIL: Index not restored'
"
# Re-apply for further testing
bundle exec rails db:migrate:up:main VERSION=202609190435083. Verify encryption-required behavior
bundle exec rails console# Create a user and verify token behavior
user = User.find(1) # or create(:user) in test env
token = user.static_object_token
# Verify the accessor returns a token
puts "Token: #{token}" # Should print a 20-char token
# Verify the plaintext column is NOT written (ignored)
result = User.connection.execute("SELECT static_object_token FROM users WHERE id = #{user.id}")
puts "Plaintext column value: #{result.first['static_object_token'].inspect}" # Should be nil
# Verify the encrypted column has data
puts "Encrypted column: #{user[:static_object_token_encrypted].present?}" # Should be true
# Verify finder works via encrypted column
found = User.find_by_static_object_token(token)
puts "Finder works: #{found&.id == user.id}" # Should be true
# Verify token reset works
user.reset_static_object_token!
new_token = user.static_object_token
puts "Token changed: #{new_token != token}" # Should be true4. Run affected specs
# Core token behavior
bundle exec rspec spec/models/user_spec.rb:3478 # static object token examples
# Auth finders
bundle exec rspec spec/lib/gitlab/auth/auth_finders_spec.rb
# Controllers using the token
bundle exec rspec spec/controllers/concerns/static_object_external_storage_spec.rb
bundle exec rspec spec/controllers/projects/raw_controller_spec.rb
bundle exec rspec spec/controllers/projects/repositories_controller_spec.rb
# Helpers
bundle exec rspec spec/helpers/access_tokens_helper_spec.rb
bundle exec rspec spec/helpers/application_helper_spec.rb
# EE audit spec
bundle exec rspec ee/spec/lib/audit_events/changes_spec.rbDatabase
Migration details
- Type: Post-deployment migration (
db/post_migrate/) - Class:
DropUsersStaticObjectTokenIndex < Gitlab::Database::Migration[2.3] - Milestone:
19.5 - Transaction: Disabled (
disable_ddl_transaction!) - Operation:
remove_concurrent_index_by_name :users, :index_users_on_static_object_token
Migration output (up)
== 20260919043508 DropUsersStaticObjectTokenIndex: migrating ==================
-- remove_concurrent_index_by_name(:users, :index_users_on_static_object_token)
-> 0.0234s
== 20260919043508 DropUsersStaticObjectTokenIndex: migrated (0.0235s) =========Migration output (down)
== 20260919043508 DropUsersStaticObjectTokenIndex: reverting ==================
-- add_concurrent_index(:users, :static_object_token, {:unique=>true, :name=>:index_users_on_static_object_token})
-> 0.0456s
== 20260919043508 DropUsersStaticObjectTokenIndex: reverted (0.0457s) =========Index being dropped
- Name:
index_users_on_static_object_token - Type: UNIQUE btree index
- Column:
static_object_token(plaintext column, now ignored) - Rationale: The index is no longer needed because the plaintext column is ignored and will be dropped in 19.6. All queries now use the encrypted column.
Zero-downtime considerations
Dropping a UNIQUE index while the column is still present (but ignored) is safe:
- The application code (19.5+) does not write to the plaintext column (due to
ignore_column) - The application code does not read from the plaintext column (due to
encrypted: :required) - No queries rely on this index for correctness (uniqueness is enforced at the application level via
generate_available_token) - The index was a performance optimization, not a correctness constraint
No live code has written a non-NULL plaintext value since the :optional strategy shipped in !76684 (merged) (2022): Authn::TokenField::Encrypted#set_token explicitly nils the plaintext column on every write under :optional. The UNIQUE index has therefore been guarding an effectively all-NULL column for years, so dropping it cannot cause a collision or correctness regression. The :required flip in this MR only removes the read-side plaintext fallback; it does not change what gets written.
Risks and rollout
Encryption strategy flip (:optional → :required)
Risk: If any code path still reads the plaintext column directly (via read_attribute(:static_object_token)), it will break.
Mitigation: Verified via git grep that no code reads the plaintext column directly. All callers use the accessor (user.static_object_token), which resolves via the encrypted column when encrypted: :required.
Rollback: If issues arise, revert this MR and the application returns to :optional behavior: reads resolve from the encrypted column and fall back to plaintext only if the encrypted column is blank. Since !76684 (merged) already populated every encrypted value, this fallback is inert in practice.
Index drop timing
Risk: Dropping the UNIQUE index could theoretically allow duplicate plaintext tokens to be written during the deploy window.
Mitigation:
- The index drop is a post-deployment migration, so it runs AFTER all app servers are on 19.5
- By the time the index drops, all app servers have
ignore_columnactive and are not writing to the plaintext column - Uniqueness is enforced at the application level via
generate_available_token, which checks for collisions before accepting a token
Rollback: The migration is reversible. Running db:migrate:down will restore the UNIQUE index.
Follow-up work (19.6)
This MR does NOT drop the static_object_token column. That will happen in 19.6 via a separate MR that:
- Removes the
ignore_columnline fromapp/models/user.rb - Adds a post-deployment migration with
remove_column :users, :static_object_token
MR acceptance checklist
Please evaluate this MR against the MR acceptance checklist. Relevant items:
- Code review guidelines followed
- Tests added for new behavior (specs updated to remove plaintext token writers)
- Database review completed (post-deployment migration, reversible, zero-downtime safe)
- Documentation not required (internal column deprecation, no user-facing changes beyond encryption enforcement)
- Changelog entry added (via
Changelog: changedcommit trailer) - Migration follows database migration guidelines
- Migration tested locally (up/down verified on all three databases: main, ci, sec)