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:

  1. Sets the encryption strategy to :required (all reads/writes now use static_object_token_encrypted only)
  2. Adds ignore_column :static_object_token to prevent application code from referencing the plaintext column
  3. Drops the remaining UNIQUE index index_users_on_static_object_token via 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:

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=20260919043508

3. 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 true

4. 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.rb

Database

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_column active 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:

  1. Removes the ignore_column line from app/models/user.rb
  2. 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: changed commit trailer)
  • Migration follows database migration guidelines
  • Migration tested locally (up/down verified on all three databases: main, ci, sec)
Edited by Kerri Miller

Merge request reports

Loading
Loading