Merge remote-tracking branch 'origin/master' into codex/tur-30-transaction-log

This commit is contained in:
Daniel Schädler 2026-04-30 14:07:12 +02:00
commit 54975d969b
10 changed files with 181 additions and 10 deletions

View File

@ -338,9 +338,7 @@ class TournamentsController < ApplicationController
token = request.authorization.to_s.delete_prefix('Bearer ').presence || request.headers['X-Tournament-Sync-Token'].to_s
return render json: { error: 'Missing sync token' }, status: :unauthorized if token.blank?
matches = token.bytesize == @tournament.sync_auth_token.to_s.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(token, @tournament.sync_auth_token.to_s)
return if matches
return if @tournament.sync_token_matches?(token)
render json: { error: 'Invalid sync token' }, status: :unauthorized
end

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'securerandom'
require 'openssl'
class Tournament < ApplicationRecord
TIMER_MODES = %w[countdown countup].freeze
@ -27,6 +28,7 @@ class Tournament < ApplicationRecord
after_initialize :generate_code
after_create_commit :ensure_default_beamer!
before_validation :normalize_timer_reason
before_validation :protect_follower_sync_token
before_validation :clear_follower_sync_token_when_disabling_read_only_mode
after_commit :broadcast_timer_state_change, if: :saved_change_to_timer_state?
@ -51,7 +53,27 @@ class Tournament < ApplicationRecord
end
def sync_accepts_push?
read_only_mode? && sync_auth_token.present?
read_only_mode? && sync_auth_configured?
end
def sync_auth_configured?
sync_auth_token.present? || sync_auth_token_digest.present?
end
def sync_token_matches?(token)
return false if token.blank?
if sync_auth_token_digest.present?
expected_digest = self.class.sync_auth_token_digest(token)
return ActiveSupport::SecurityUtils.secure_compare(expected_digest, sync_auth_token_digest)
end
token.bytesize == sync_auth_token.to_s.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(token, sync_auth_token.to_s)
end
def self.sync_auth_token_digest(token)
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
end
private
@ -81,15 +103,15 @@ class Tournament < ApplicationRecord
end
def sync_configuration_blank?
sync_target_url.blank? && sync_auth_token.blank?
sync_target_url.blank? && !sync_auth_configured?
end
def sync_configuration_complete?
sync_target_url.present? && sync_auth_token.present?
sync_target_url.present? && sync_auth_configured?
end
def follower_sync_token_only?
read_only_mode? && sync_auth_token.present? && sync_target_url.blank?
read_only_mode? && sync_auth_configured? && sync_target_url.blank?
end
def ensure_default_beamer!
@ -101,11 +123,24 @@ class Tournament < ApplicationRecord
def clear_follower_sync_token_when_disabling_read_only_mode
return if read_only_mode?
self.sync_auth_token_digest = nil
return if sync_target_url.present?
self.sync_auth_token = nil
end
def protect_follower_sync_token
return unless read_only_mode?
if sync_auth_token.present?
self.sync_auth_token_digest = self.class.sync_auth_token_digest(sync_auth_token)
self.sync_auth_token = nil
elsif will_save_change_to_sync_auth_token? && sync_auth_token.blank?
self.sync_auth_token_digest = nil
end
end
def normalize_timer_reason
self.timer_reason = timer_reason.presence
self.timer_reason_text = timer_reason_text&.strip&.presence

View File

@ -5,6 +5,7 @@ class TournamentSerializer < SimpleTournamentSerializer
:instant_finalists_amount, :intermediate_round_participants_amount
attribute :read_only_mode, if: :sync_metadata_visible?
attribute :sync_target_url, if: :sync_metadata_visible?
attribute :sync_auth_configured, if: :sync_metadata_visible?
attribute :sync_last_push_error, if: :sync_metadata_visible?
# NEVER expose sync_auth_token anywhere - it should only ever be written to or checked against
@ -41,4 +42,8 @@ class TournamentSerializer < SimpleTournamentSerializer
def sync_metadata_visible?
scope.present? && scope == object.owner
end
def sync_auth_configured
object.sync_auth_configured?
end
end

View File

@ -24,6 +24,7 @@ class TournamentSyncSchema
read_only_mode
sync_target_url
sync_auth_token
sync_auth_token_digest
sync_source_tournament_id
sync_last_pushed_at
sync_last_push_error

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
require 'openssl'
class AddSyncAuthTokenDigestToTournaments < ActiveRecord::Migration[7.0]
class MigrationTournament < ActiveRecord::Base
self.table_name = 'tournaments'
end
def up
unless column_exists?(:tournaments, :sync_auth_token_digest)
add_column :tournaments, :sync_auth_token_digest, :string
end
MigrationTournament.reset_column_information
MigrationTournament
.where(read_only_mode: true)
.where.not(sync_auth_token: [nil, ''])
.find_each do |tournament|
tournament.update!(
sync_auth_token: nil,
sync_auth_token_digest: sync_auth_token_digest(tournament.sync_auth_token)
)
end
end
def down
remove_column :tournaments, :sync_auth_token_digest if column_exists?(:tournaments, :sync_auth_token_digest)
end
private
def sync_auth_token_digest(token)
OpenSSL::HMAC.hexdigest('SHA256', Rails.application.secret_key_base, token.to_s)
end
end

View File

@ -172,6 +172,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_30_120000) do
t.boolean "public", default: true
t.boolean "read_only_mode", default: false, null: false
t.string "sync_auth_token"
t.string "sync_auth_token_digest"
t.datetime "sync_last_imported_snapshot_at"
t.string "sync_last_push_error"
t.datetime "sync_last_pushed_at"

View File

@ -24,6 +24,7 @@ Create empty tournament with:
- `sync_auth_token: <shared secret>`
Do not send `teams` payload for follower. Backend allows empty read only follower tournament creation.
The token is write-only. Backend responses never return it; owner responses only expose `sync_auth_configured`.
### 2. Create normal leader tournament on local backend
@ -178,9 +179,9 @@ Owner-facing frontend can expose:
Recommended owner UX:
1. create remote follower first
2. copy remote `sync_auth_token`
3. paste remote `sync_state` URL into leader
1. generate a shared token outside the backend
2. create remote follower with that `sync_auth_token`
3. paste same token and remote `sync_state` URL into leader
4. save leader sync config
5. show last push time / error state
6. allow explicit follower takeover by disabling `read_only_mode`

View File

@ -150,9 +150,11 @@ RSpec.describe TournamentsController, type: :controller do
json = deserialize_response(response)
expect(json[:read_only_mode]).to eq(true)
expect(json[:sync_target_url]).to eq('https://remote.example.com/tournaments/1/sync_state')
expect(json[:sync_auth_configured]).to eq(true)
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
expect(json[:sync_last_push_error]).to eq('push failed')
expect(json).not_to have_key(:sync_auth_token)
expect(json).not_to have_key(:sync_auth_token_digest)
expect(json).to eq(TournamentSerializer.new(@tournament, scope: @tournament.owner).as_json)
end
@ -389,8 +391,12 @@ RSpec.describe TournamentsController, type: :controller do
expect(response).to have_http_status(:created)
tournament = Tournament.find(deserialize_response(response)[:id])
expect(tournament.read_only_mode?).to eq(true)
expect(tournament.sync_auth_token).to be_nil
expect(tournament.sync_auth_token_digest).to be_present
expect(tournament.teams).to be_empty
expect(tournament.stages).to be_empty
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
end
end
@ -602,6 +608,9 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to include('/sync_state')
expect(@tournament.sync_auth_token).to eq('new-token')
expect(@tournament.sync_auth_token_digest).to be_nil
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
end
it 'allows follower takeover when only read_only_mode is disabled' do
@ -618,6 +627,7 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.read_only_mode?).to eq(false)
expect(@tournament.sync_target_url).to be_blank
expect(@tournament.sync_auth_token).to be_blank
expect(@tournament.sync_auth_token_digest).to be_blank
end
it 'blocks normal updates while tournament is read only' do
@ -818,6 +828,8 @@ RSpec.describe TournamentsController, type: :controller do
expect(@tournament.sync_source_tournament_id).to eq(123)
expect(@tournament.name).to eq('Synced Tournament')
expect(@tournament.teams.pluck(:name)).to eq(['Alpha'])
expect(deserialize_response(response)).not_to have_key(:sync_auth_token)
expect(deserialize_response(response)).not_to have_key(:sync_auth_token_digest)
end
it 'rejects invalid tokens' do
@ -857,6 +869,9 @@ RSpec.describe TournamentsController, type: :controller do
expect(json[:id]).to eq(@tournament.id)
expect(json[:sync_last_pushed_at]).to eq(pushed_at.iso8601)
expect(json[:sync_last_push_error]).to be_nil
expect(json[:sync_auth_configured]).to eq(true)
expect(json).not_to have_key(:sync_auth_token)
expect(json).not_to have_key(:sync_auth_token_digest)
expect(TournamentSyncPusher).to have_received(:push!).with(@tournament)
end

View File

@ -44,6 +44,8 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
expect(test_sync[:status]).to eq(200)
expect(test_sync.dig(:json, :sync_last_pushed_at)).not_to be_nil
expect(test_sync.dig(:json, :sync_last_push_error)).to be_nil
expect(test_sync.dig(:json, :sync_auth_configured)).to eq(true)
expect_no_sync_token_exposed!(test_sync, sync_token)
after_leader = fetch_tournament(leader.fetch(:id))
after_follower = fetch_tournament(follower.fetch(:id))
@ -54,6 +56,41 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
.to match_array(after_leader.fetch(:teams).map { |team| team.fetch(:name) })
end
it 'never exposes sync auth token after create, update, show, or sync test' do
follower_create = client.post('/tournaments', body: {
name: "Protected Follower #{SecureRandom.hex(3)}",
description: 'Follower token should stay write only',
public: true,
read_only_mode: true,
sync_auth_token: sync_token
})
expect(follower_create[:status]).to eq(201)
expect_no_sync_token_exposed!(follower_create, sync_token)
follower_id = follower_create.dig(:json, :id)
follower_owner_read = client.get("/tournaments/#{follower_id}")
expect(follower_owner_read[:status]).to eq(200)
expect(follower_owner_read.dig(:json, :sync_auth_configured)).to eq(true)
expect_no_sync_token_exposed!(follower_owner_read, sync_token)
follower_public_read = anonymous_client.get("/tournaments/#{follower_id}")
expect(follower_public_read[:status]).to eq(200)
expect_no_sync_token_exposed!(follower_public_read, sync_token)
leader = create_group_stage_tournament(name_prefix: 'Protected Leader')
configure_sync = client.patch("/tournaments/#{leader.fetch(:id)}", body: {
sync_target_url: "#{base_url}/tournaments/#{follower_id}/sync_state",
sync_auth_token: sync_token
})
expect(configure_sync[:status]).to eq(200)
expect(configure_sync.dig(:json, :sync_auth_configured)).to eq(true)
expect_no_sync_token_exposed!(configure_sync, sync_token)
test_sync = client.post("/tournaments/#{leader.fetch(:id)}/test_sync")
expect(test_sync[:status]).to eq(200)
expect_no_sync_token_exposed!(test_sync, sync_token)
end
it 'syncs team action list changes made through item id route and business key route' do
follower = create_follower_tournament(name_prefix: 'Sync Action Follower')
leader = create_group_stage_tournament(
@ -170,6 +207,14 @@ RSpec.describe 'Tournament sync test HTTP E2E' do
response.fetch(:json)
end
def expect_no_sync_token_exposed!(response, token)
serialized_json = response.fetch(:json).inspect
expect(serialized_json).not_to include('sync_auth_token')
expect(serialized_json).not_to include('sync_auth_token_digest')
expect(serialized_json).not_to include(token)
end
def finish_group_stage_and_create_playoffs!(tournament_id)
tournament = fetch_tournament(tournament_id)
group_stage = tournament.fetch(:stages).find { |stage| stage.fetch(:level) == -1 }

View File

@ -43,6 +43,40 @@ RSpec.describe Tournament, type: :model do
end
end
describe 'sync auth token protection' do
it 'stores follower receiver tokens only as a digest' do
tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
expect(tournament.sync_auth_token).to be_nil
expect(tournament.sync_auth_token_digest).to be_present
expect(tournament.sync_token_matches?('shared-secret')).to eq(true)
expect(tournament.sync_token_matches?('wrong-secret')).to eq(false)
expect(tournament.sync_accepts_push?).to eq(true)
end
it 'keeps leader push token available for outbound sync' do
tournament = create(
:tournament,
sync_target_url: 'https://remote.example.com/tournaments/1/sync_state',
sync_auth_token: 'shared-secret'
)
expect(tournament.sync_auth_token).to eq('shared-secret')
expect(tournament.sync_auth_token_digest).to be_nil
expect(tournament.sync_push_enabled?).to eq(true)
end
it 'clears follower token digest on takeover' do
tournament = create(:tournament, read_only_mode: true, sync_auth_token: 'shared-secret')
tournament.update!(read_only_mode: false)
expect(tournament.sync_auth_token).to be_nil
expect(tournament.sync_auth_token_digest).to be_nil
expect(tournament.sync_auth_configured?).to eq(false)
end
end
describe '#matches' do
context 'group stage tournament' do
before do