Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,23 @@ test: ## Run the test suite in parallel

check: ## Run setup checks
bundle exec rake setup:check

detect_subkeyed_members: ## List sub-keyed members from a local production dump
DB_NAME=$(DUMP_DB) bundle exec rake member:subkeyed:detect

fix_subkeyed_members: ## Dry-run sub-keyed member deactivation on the dump (EXECUTE=1 executes)
DB_NAME=$(DUMP_DB) bundle exec rake member:subkeyed:deactivate

verify_subkeyed_members: ## Verify no unhandled sub-keyed members remain in the dump
DB_NAME=$(DUMP_DB) bundle exec rake member:subkeyed:verify

detect_subkeyed_members_production: ## List sub-keyed members on the PRODUCTION database
@read -p "Connect to PRODUCTION database? [y/N] " ans && [ "$$ans" = "y" ] || exit 1
@DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:subkeyed:detect

fix_subkeyed_members_production: ## Dry-run deactivation on PRODUCTION (EXECUTE=1 executes)
@read -p "This can MODIFY the PRODUCTION database. Continue? [y/N] " ans && [ "$$ans" = "y" ] || exit 1
@DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:subkeyed:deactivate

verify_subkeyed_members_production: ## Verify no unhandled sub-keyed members remain on PRODUCTION
@DB_URL=$$(heroku config:get DATABASE_URL --app=$(DUMP_APP)) bundle exec rake member:subkeyed:verify
126 changes: 126 additions & 0 deletions app/services/subkeyed_member_cleanup.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# frozen_string_literal: true

# Find and deactivate members keyed on the better-auth user id that the
# planner's id_token sub fallback used as an email (codebar planner,
# 2026-10-02 incident). These members share no name or activity evidence
# with their real account, so name/email duplicate heuristics cannot find
# them. The evidence is planner-side and deterministic: the member's email
# equals its own codebar auth service uid, that shared value contains no
# '@', and the member was created after the auth-flow cutoff.
#
# Cleanup is deactivate-only: no merge target is knowable from the planner
# database (the user-id-to-real-account mapping lives in the auth app's
# database), and guessing a merge target repeats the false-positive merge
# harm the duplicate tooling already guards against (member 31336 / 25796).
# A detected member owning data is skipped, never touched, and counts as
# handled: verify passes while skips are reported, so the "nothing
# unhandled" end state is reachable even when skips remain.
#
# Output is structured (Result/Action); the rake task owns all reporting.
class SubkeyedMemberCleanup
RESULT_FIELDS = %i[detected deactivated skipped actions].freeze

Action = Data.define(:member_id, :email, :outcome, :owned)
Result = Data.define(*RESULT_FIELDS)

# The merge of the /auth/codebar sign-in flow into planner (2026-08-06):
# sub-keyed members cannot exist before this point.
CUTOFF_TIME = Time.utc(2026, 8, 6, 15, 25, 11)

# Email prefixes that mark a member already handled by this cleanup or by
# the tooling it follows (the duplicate-merge rename and member deletion).
HANDLED_EMAIL_PREFIXES = ['subkeyed.', 'duplicate.', 'deleted_user_'].freeze

# Member associations that prove the member holds real data (skip rule):
# any of these and the member is reported, not deactivated.
OWNED_DATA = {
subscriptions: 'subscriptions',
workshop_invitations: 'workshop invitations',
meeting_invitations: 'meeting invitations',
invitations: 'event invitations',
roles: 'roles',
bans: 'bans',
feedbacks: 'feedbacks',
member_notes: 'member notes'
}.freeze

def self.call(dry_run: true)
new(dry_run:).call
end

def initialize(dry_run: true)
@dry_run = dry_run
end

def call
actions = detectable_members.map { |member| deactivate(member) }

Result.new(detected: actions.size,
deactivated: actions.count { |a| a.outcome == :deactivated },
skipped: actions.count { |a| a.outcome == :skipped },
actions:)
end

def detectable_members
prefix_exclusions = HANDLED_EMAIL_PREFIXES.map { |prefix| Member.where('members.email LIKE ?', "#{prefix}%") }

prefix_exclusions.reduce(detection_scope) { |rel, exclusion| rel.where.not(id: exclusion) }
.order(:id)
.to_a
end

# Verify support: partition detectable members into the set this cleanup
# can and should have deactivated (unhandled) and the set that owns data
# (handled by report; never touched).
def verification
unhandled, skipped = detectable_members.partition { |member| data_owned_by(member).none? }
{ unhandled: unhandled.map(&:id), skipped: skipped.map(&:id) }
end

private

def detection_scope
Member.joins(:auth_services)
.where(auth_services: { provider: 'codebar' })
.where('members.created_at > ?', CUTOFF_TIME)
.where('members.email = auth_services.uid')
.where("position('@' in members.email) = 0")
end

def deactivate(member)
owned = data_owned_by(member)
return skipped_action(member, owned) if owned.any?

return deactivated_action(member) if @dry_run

persist_deactivation(member)
deactivated_action(member)
end

def skipped_action(member, owned)
Action.new(member_id: member.id, email: member.email, outcome: :skipped, owned:)
end

def deactivated_action(member)
Action.new(member_id: member.id, email: member.email, outcome: :deactivated, owned: [])
end

def persist_deactivation(member)
original_email = member.email
ActiveRecord::Base.transaction do
member.auth_services.destroy_all
member.update_columns(email: "subkeyed.#{member.id}.deactivated@codebar.io") # rubocop:disable Rails/SkipsModelValidations -- renames fail normal validation by design
member.member_notes.create!(note: note_body(original_email), author_id: member.id)
end
end

def data_owned_by(member)
OWNED_DATA.filter_map { |relation, label| label if member.public_send(relation).exists? }
end

def note_body(original_email)
'Sub-keyed on the better-auth user id (id_token sub fallback). ' \
"Original email/uid: #{original_email}. Deactivated #{Time.zone.now.iso8601}. " \
'Login will stay off until the fail-closed strategy guard ships; re-login re-creates the member.'
end
end
126 changes: 126 additions & 0 deletions lib/tasks/subkeyed_members.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# frozen_string_literal: true

# Detect, deactivate (dry run unless EXECUTE=1), and verify members keyed on
# the auth-app user id. Logic in app/services/subkeyed_member_cleanup.rb;
# this task owns all operator-facing output.
#
# Usage:
# List matches (read-only):
# rake member:subkeyed:detect
#
# Dry-run the deactivation (default; prints what would happen):
# rake member:subkeyed:deactivate
#
# Deactivate for real (one transaction per member):
# EXECUTE=1 rake member:subkeyed:deactivate
#
# Confirm the end state (exits 1 while deactivatable members remain):
# rake member:subkeyed:verify
#
# Run against a local production dump instead of the environment's own
# database:
# DB_NAME=codebar_production_dump rake member:subkeyed:detect
#
# On Heroku (uses the app's DATABASE_URL; no DB_NAME needed):
# heroku run rake member:subkeyed:detect --app codebar-production
# heroku run rake member:subkeyed:deactivate EXECUTE=1 --app codebar-production
#
# Sequencing: run EXECUTE=1 in production only after the fail-closed
# strategy guard (#2986) is deployed.

def print_actions(result, dry_run: true)
result.actions.each { |action| print_action(action, dry_run) }
end

def print_action(action, dry_run)
case action.outcome
when :skipped
puts "Skipping sub-keyed member #{action.member_id} (#{action.email}) — owns #{action.owned.join(', ')}. " \
'Review manually; merging needs planner-external evidence (an auth-DB link), never a guess.'
when :deactivated
verb = dry_run ? 'Would deactivate' : 'Deactivating'
puts " #{verb} sub-keyed member #{action.member_id} (#{action.email}): " \
'remove auth services, rename email, add member note'
end
end

def print_counts(result)
puts "detected=#{result.detected} deactivated=#{result.deactivated} skipped=#{result.skipped}"
end

def print_summary(result, dry_run:)
puts 'DRY RUN — re-run with EXECUTE=1 to deactivate.' if dry_run
print_counts(result)
puts 'Skipped members count as handled: review them manually before any merge.'
end

# When DB_URL or DB_NAME is set, connect to that database (a live production
# database or a local dump) instead of the environment's own. DB_URL takes a
# full connection URL; DB_NAME is a local database on DB_HOST. Otherwise the
# task uses the Rails environment's database (e.g. Heroku's DATABASE_URL).
def connect_to_dump_if_requested
if ENV['DB_URL'].present?
connect_to_remote(ENV['DB_URL'])
elsif ENV['DB_NAME']
connect_to_local(ENV['DB_NAME'])
end
end

def connect_to_local(database)
ActiveRecord::Base.establish_connection(
adapter: 'postgresql',
host: ENV.fetch('DB_HOST', '/var/run/postgresql'),
port: ENV.fetch('DB_PORT', 5432),
database:,
username: ENV['DB_USER'] || '',
password: ENV['POSTGRES_PASSWORD'] || ''
)
end

def connect_to_remote(db_url)
url = URI.parse(db_url)
unless %w[localhost 127.0.0.1].include?(url.host)
puts "WARNING: Connecting to REMOTE database #{url.host}"
end
ActiveRecord::Base.establish_connection(db_url)
end

namespace :member do
namespace :subkeyed do
desc 'Detect members keyed on the auth-app user id (id_token sub fallback incident)'
task detect: :environment do
connect_to_dump_if_requested
result = SubkeyedMemberCleanup.call(dry_run: true)

print_actions(result)
print_counts(result)
end

desc 'Deactivate sub-keyed members (dry run by default; EXECUTE=1 to write)'
task deactivate: :environment do
connect_to_dump_if_requested
dry_run = ENV['EXECUTE'] != '1'
result = SubkeyedMemberCleanup.call(dry_run:)

print_actions(result, dry_run:)
print_summary(result, dry_run:)
end

desc 'Verify no unhandled sub-keyed members remain (deactivatable-but-active fails)'
task verify: :environment do
connect_to_dump_if_requested
verification = SubkeyedMemberCleanup.new.verification
unhandled = verification[:unhandled]
skipped = verification[:skipped]

if unhandled.empty?
puts 'PASS: no deactivatable sub-keyed members remain.'
puts "Note: #{skipped.size} skipped member(s) count as handled — review manually." if skipped.any?
else
puts "FAIL: #{unhandled.size} deactivatable sub-keyed member(s) still active:"
unhandled.each { |id| puts " member #{id}" }
exit 1
end
end
end
end
Loading
Loading