Commit 45619cb1 authored by cmrd Senya's avatar cmrd Senya

Account migration model and message support

This commit introduces support for AccountMigration federation message
receive. It covers the cases when the new home pod for a user is remote
respective to the recepient pod of the message. It also allows to initiate
migration locally by a podmin from the rails console. This will give the
pods a possibility to understand the account migration event on the
federation level and thus future version which will implement migration
will be backward compatible with the pods starting from this commit.
parent e2979df6
class AccountMigration < ApplicationRecord
include Diaspora::Federated::Base
belongs_to :old_person, class_name: "Person"
belongs_to :new_person, class_name: "Person"
validates :old_person, uniqueness: true
validates :new_person, uniqueness: true
after_create :lock_old_user!
attr_accessor :old_private_key
def receive(*)
perform!
end
def public?
true
end
def sender
@sender ||= old_user || ephemeral_sender
end
# executes a migration plan according to this AccountMigration object
def perform!
raise "already performed" if performed?
ActiveRecord::Base.transaction do
account_deleter.tombstone_person_and_profile
account_deleter.close_user if user_left_our_pod?
account_deleter.tombstone_user if user_changed_id_locally?
update_all_references
end
dispatch if locally_initiated?
dispatch_contacts if remotely_initiated?
end
def performed?
old_person.closed_account?
end
# We assume that migration message subscribers are people that are subscribed to a new user profile updates.
# Since during the migration we update contact references, this includes all the contacts of the old person.
# In case when a user migrated to our pod from a remote one, we include remote person to subscribers so that
# the new pod is informed about the migration as well.
def subscribers
new_user.profile.subscribers.remote.to_a.tap do |subscribers|
subscribers.push(old_person) if old_person.remote?
end
end
private
# Normally pod initiates migration locally when the new user is local. Then the pod creates AccountMigration object
# itself. If new user is remote, then AccountMigration object is normally received via the federation and this is
# remote initiation then.
def remotely_initiated?
new_person.remote?
end
def locally_initiated?
!remotely_initiated?
end
def old_user
old_person.owner
end
def new_user
new_person.owner
end
def lock_old_user!
old_user&.lock_access!
end
def user_left_our_pod?
old_user && !new_user
end
def user_changed_id_locally?
old_user && new_user
end
# We need to resend contacts of users of our pod for the remote new person so that the remote pod received this
# contact information from the authoritative source.
def dispatch_contacts
new_person.contacts.sharing.each do |contact|
Diaspora::Federation::Dispatcher.defer_dispatch(contact.user, contact)
end
end
def dispatch
Diaspora::Federation::Dispatcher.build(sender, self).dispatch
end
EphemeralUser = Struct.new(:diaspora_handle, :serialized_private_key) do
def id
diaspora_handle
end
def encryption_key
OpenSSL::PKey::RSA.new(serialized_private_key)
end
end
def ephemeral_sender
raise "can't build sender without old private key defined" if old_private_key.nil?
EphemeralUser.new(old_person.diaspora_handle, old_private_key)
end
def update_all_references
update_person_references
update_user_references if user_changed_id_locally?
end
def person_references
references = Person.reflections.reject {|key, _|
%w[profile owner notifications pod].include?(key)
}
references.map {|key, value|
{value.foreign_key => key}
}
end
def user_references
references = User.reflections.reject {|key, _|
%w[
person profile auto_follow_back_aspect invited_by aspect_memberships contact_people followed_tags
ignored_people conversation_visibilities pairwise_pseudonymous_identifiers conversations o_auth_applications
].include?(key)
}
references.map {|key, value|
{value.foreign_key => key}
}
end
def update_person_references
logger.debug "Updating references from person id=#{old_person.id} to person id=#{new_person.id}"
update_references(person_references, old_person, new_person.id)
end
def update_user_references
logger.debug "Updating references from user id=#{old_user.id} to user id=#{new_user.id}"
update_references(user_references, old_user, new_user.id)
end
def update_references(references, object, new_id)
references.each do |pair|
key_id = pair.flatten[0]
association = pair.flatten[1]
object.send(association).update_all(key_id => new_id)
end
end
def account_deleter
@account_deleter ||= AccountDeleter.new(old_person)
end
end
......@@ -40,7 +40,10 @@ class Person < ApplicationRecord
has_many :likes, foreign_key: :author_id, dependent: :destroy # This person's own likes
has_many :participations, :foreign_key => :author_id, :dependent => :destroy
has_many :poll_participations, foreign_key: :author_id, dependent: :destroy
has_many :conversation_visibilities
has_many :conversation_visibilities, dependent: :destroy
has_many :messages, foreign_key: :author_id, dependent: :destroy
has_many :conversations, foreign_key: :author_id, dependent: :destroy
has_many :blocks, dependent: :destroy
has_many :roles
......@@ -307,11 +310,6 @@ class Person < ApplicationRecord
serialized_public_key
end
def exported_key= new_key
raise "Don't change a key" if serialized_public_key
serialized_public_key = new_key
end
# discovery (webfinger)
def self.find_or_fetch_by_identifier(diaspora_id)
# exiting person?
......
......@@ -126,6 +126,7 @@ class Profile < ApplicationRecord
end
def tombstone!
@tag_string = nil
self.taggings.delete_all
clearable_fields.each do |field|
self[field] = nil
......
......@@ -54,6 +54,8 @@ class User < ApplicationRecord
belongs_to :auto_follow_back_aspect, class_name: "Aspect", optional: true
belongs_to :invited_by, class_name: "User", optional: true
has_many :invited_users, class_name: "User", inverse_of: :invited_by, foreign_key: :invited_by_id
has_many :aspect_memberships, :through => :aspects
has_many :contacts
......
class CreateAccountMigrations < ActiveRecord::Migration[5.1]
def change
create_table :account_migrations do |t|
t.integer :old_person_id, null: false
t.integer :new_person_id, null: false
end
add_foreign_key :account_migrations, :people, column: :old_person_id
add_foreign_key :account_migrations, :people, column: :new_person_id
add_index :account_migrations, %i[old_person_id new_person_id], unique: true
add_index :account_migrations, :old_person_id, unique: true
end
end
......@@ -30,18 +30,20 @@ class AccountDeleter
delete_contacts_of_me
tombstone_person_and_profile
if self.user
#user deletion methods
remove_share_visibilities_on_contacts_posts
delete_standard_user_associations
disconnect_contacts
tombstone_user
end
close_user if user
mark_account_deletion_complete
end
end
# user deletion methods
def close_user
remove_share_visibilities_on_contacts_posts
disconnect_contacts
delete_standard_user_associations
tombstone_user
end
#user deletions
def normal_ar_user_associates_to_delete
%i[tag_followings services aspects user_preferences
......@@ -53,7 +55,7 @@ class AccountDeleter
end
def ignored_ar_user_associations
%i[followed_tags invited_by contact_people aspect_memberships
%i[followed_tags invited_by invited_users contact_people aspect_memberships
ignored_people share_visibilities conversation_visibilities conversations reports]
end
......@@ -70,7 +72,7 @@ class AccountDeleter
end
def disconnect_contacts
user.contacts.reload.destroy_all
user.contacts.destroy_all
end
# Currently this would get deleted due to the db foreign key constrainsts,
......@@ -97,12 +99,12 @@ class AccountDeleter
end
def normal_ar_person_associates_to_delete
%i[posts photos mentions participations roles]
%i[posts photos mentions participations roles blocks]
end
def ignored_or_special_ar_person_associations
%i[comments likes poll_participations contacts notification_actors notifications owner profile
conversation_visibilities pod]
conversation_visibilities pod conversations messages]
end
def mark_account_deletion_complete
......
......@@ -22,6 +22,13 @@ module Diaspora
)
end
def self.account_migration(account_migration)
DiasporaFederation::Entities::AccountMigration.new(
author: account_migration.sender.diaspora_handle,
profile: profile(account_migration.new_person.profile)
)
end
def self.comment(comment)
DiasporaFederation::Entities::Comment.new(
{
......
......@@ -6,6 +6,7 @@ module Diaspora
# used in Diaspora::Federation::Receive
def self.receiver_for(federation_entity)
case federation_entity
when DiasporaFederation::Entities::AccountMigration then :account_migration
when DiasporaFederation::Entities::Comment then :comment
when DiasporaFederation::Entities::Contact then :contact
when DiasporaFederation::Entities::Conversation then :conversation
......@@ -24,6 +25,7 @@ module Diaspora
# used in Diaspora::Federation::Entities
def self.builder_for(diaspora_entity)
case diaspora_entity
when AccountMigration then :account_migration
when AccountDeletion then :account_deletion
when Comment then :comment
when Contact then :contact
......
......@@ -11,6 +11,14 @@ module Diaspora
AccountDeletion.create!(person: author_of(entity))
end
def self.account_migration(entity)
profile = profile(entity.profile)
AccountMigration.create!(
old_person: Person.by_account_identifier(entity.author),
new_person: profile.person
)
end
def self.comment(entity)
receive_relayable(Comment, entity) do
Comment.new(
......
......@@ -53,6 +53,11 @@ FactoryGirl.define do
association :person
end
factory :account_migration do
association :old_person, factory: :person
association :new_person, factory: :person
end
factory :like do
association :author, :factory => :person
association :target, :factory => :status_message
......@@ -145,6 +150,11 @@ FactoryGirl.define do
end
end
factory(:share_visibility) do
user
association :shareable, factory: :status_message
end
factory(:location) do
sequence(:address) {|n| "Fernsehturm Berlin, #{n}, Berlin, Germany" }
sequence(:lat) {|n| 52.520645 + 0.0000001 * n }
......@@ -222,13 +232,8 @@ FactoryGirl.define do
sequence(:uid) { |token| "00000#{token}" }
sequence(:access_token) { |token| "12345#{token}" }
sequence(:access_secret) { |token| "98765#{token}" }
end
factory :service_user do
sequence(:uid) { |id| "a#{id}"}
sequence(:name) { |num| "Rob Fergus the #{num.ordinalize}" }
association :service
photo_url "/assets/user/adams.jpg"
user
end
factory :pod do
......@@ -354,7 +359,18 @@ FactoryGirl.define do
text SecureRandom.hex(1000)
end
factory(:status, :parent => :status_message)
factory(:status, parent: :status_message)
factory :block do
user
person
end
factory :report do
user
association :item, factory: :status_message
text "offensive content"
end
factory :o_auth_application, class: Api::OpenidConnect::OAuthApplication do
client_name { "Diaspora Test Client #{r_str}" }
......
......@@ -12,25 +12,11 @@ describe "deleteing account", type: :request do
DataGenerator.create(subject, :generic_user_data)
end
it "deletes all of the user data" do
expect {
account_removal_method
}.to change(nil, "user preferences empty?") { UserPreference.where(user_id: user.id).empty? }.to(be_truthy)
.and(change(nil, "notifications empty?") { Notification.where(recipient_id: user.id).empty? }.to(be_truthy))
.and(change(nil, "blocks empty?") { Block.where(user_id: user.id).empty? }.to(be_truthy))
.and(change(nil, "services empty?") { Service.where(user_id: user.id).empty? }.to(be_truthy))
.and(change(nil, "share visibilities empty?") { ShareVisibility.where(user_id: user.id).empty? }.to(be_truthy))
.and(change(nil, "aspects empty?") { user.aspects.empty? }.to(be_truthy))
.and(change(nil, "contacts empty?") { user.contacts.empty? }.to(be_truthy))
.and(change(nil, "tag followings empty?") { user.tag_followings.empty? }.to(be_truthy))
.and(change(nil, "clearable fields blank?") {
user.send(:clearable_fields).map {|field|
user.reload[field].blank?
}
}.to(eq([true] * user.send(:clearable_fields).count)))
end
it_behaves_like "deletes all of the user data"
it_behaves_like "it removes the person associations"
it_behaves_like "it keeps the person conversations"
end
context "of remote person" do
......@@ -41,5 +27,13 @@ describe "deleteing account", type: :request do
end
it_behaves_like "it removes the person associations"
it_behaves_like "it keeps the person conversations"
it_behaves_like "it makes account closed and clears profile" do
before do
account_removal_method
end
end
end
end
require "integration/federation/federation_helper"
def create_remote_contact(user, pod_host)
FactoryGirl.create(
:contact,
user: user,
person: FactoryGirl.create(
:person,
pod: Pod.find_or_create_by(url: "http://#{pod_host}"),
diaspora_handle: "#{r_str}@#{pod_host}"
)
)
end
shared_examples_for "old person account is closed and profile is cleared" do
subject { old_user.person }
before do
run_migration
subject.reload
end
include_examples "it makes account closed and clears profile"
end
shared_examples_for "old person doesn't have any reference left" do
let(:person) { old_user.person }
before do
DataGenerator.create(person, :generic_person_data)
end
def account_removal_method
run_migration
person.reload
end
include_examples "it removes the person associations"
include_examples "it removes the person conversations"
end
shared_examples_for "every migration scenario" do
it_behaves_like "it updates person references"
it_behaves_like "old person account is closed and profile is cleared"
it_behaves_like "old person doesn't have any reference left"
end
shared_examples_for "migration scenarios with local old user" do
it "locks the old user account" do
run_migration
expect(old_user.reload).to be_a_locked_account
end
end
shared_examples_for "migration scenarios initiated remotely" do
it "resends known contacts to the new user" do
contacts = Array.new(2) { FactoryGirl.create(:contact, person: old_user.person, sharing: true) }
expect(DiasporaFederation::Federation::Sender).to receive(:private)
.twice do |sender_id, obj_str, _urls, _xml|
expect(sender_id).to eq(contacts.first.user_id)
expect(obj_str).to eq("Contact:#{contacts.first.user.diaspora_handle}:#{new_user.diaspora_handle}")
contacts.shift
[]
end
inlined_jobs { run_migration }
end
end
shared_examples_for "migration scenarios initiated locally" do
it "dispatches account migration message to the federation" do
expect(DiasporaFederation::Federation::Sender).to receive(:public) do |sender_id, obj_str, urls, xml|
if old_user.person.remote?
expect(sender_id).to eq(old_user.diaspora_handle)
else
expect(sender_id).to eq(old_user.id)
end
expect(obj_str).to eq("AccountMigration:#{old_user.diaspora_handle}:#{new_user.diaspora_handle}")
subscribers = [remote_contact.person]
subscribers.push(old_user) if old_user.person.remote?
expect(urls).to match_array(subscribers.map(&:url).map {|url| "#{url}receive/public" })
entity = nil
expect {
magic_env = Nokogiri::XML(xml).root
entity = DiasporaFederation::Salmon::MagicEnvelope
.unenvelop(magic_env, old_user.diaspora_handle).payload
}.not_to raise_error
expect(entity).to be_a(DiasporaFederation::Entities::AccountMigration)
expect(entity.author).to eq(old_user.diaspora_handle)
expect(entity.profile.author).to eq(new_user.diaspora_handle)
[]
end
inlined_jobs do
run_migration
end
end
end
describe "account migration" do
# this is the case when we receive account migration message from the federation
context "remotely initiated" do
let(:entity) { create_account_migration_entity(old_user.diaspora_handle, new_user) }
def run_migration
allow_callbacks(%i[queue_public_receive fetch_public_key receive_entity])
post_message(generate_payload(entity, old_user))
end
context "both new and old profiles are remote" do
include_context "with remote old user"
include_context "with remote new user"
it "creates AccountMigration db object" do
run_migration
expect(AccountMigration.where(old_person: old_user.person, new_person: new_user.person)).to exist
end
include_examples "every migration scenario"
include_examples "migration scenarios initiated remotely"
end
# this is the case when we're a pod, which was left by a person in favor of remote one
context "old user is local, new user is remote" do
include_context "with local old user"
include_context "with remote new user"
include_examples "every migration scenario"
include_examples "migration scenarios initiated remotely"
it_behaves_like "migration scenarios with local old user"
it_behaves_like "deletes all of the user data" do
let(:user) { old_user }
before do
DataGenerator.create(user, :generic_user_data)
end
def account_removal_method
run_migration
user.reload
end
end
end
end
context "locally initiated" do
before do
allow(DiasporaFederation.callbacks).to receive(:trigger).and_call_original
end
# this is the case when user migrates to our pod from a remote one
context "old user is remote and new user is local" do
include_context "with remote old user"
include_context "with local new user"
def run_migration
AccountMigration.create!(
old_person: old_user.person,
new_person: new_user.person,
old_private_key: old_user.serialized_private_key
).perform!
end
include_examples "every migration scenario"
it_behaves_like "migration scenarios initiated locally" do
let!(:remote_contact) { create_remote_contact(new_user, "remote-friend.org") }
end
end
# this is the case when a user changes diaspora id but stays on the same pod
context "old user is local and new user is local" do
include_context "with local old user"
include_context "with local new user"
def run_migration
AccountMigration.create!(old_person: old_user.person, new_person: new_user.person).perform!
end
include_examples "every migration scenario"
it_behaves_like "migration scenarios initiated locally" do
let!(:remote_contact) { create_remote_contact(old_user, "remote-friend.org") }
end
it_behaves_like "migration scenarios with local old user"
it "clears the old user account" do
run_migration
expect(old_user.reload).to be_a_clear_account
end
it_behaves_like "it updates user references"
end
end
end
......@@ -6,21 +6,43 @@ def remote_user_on_pod_c
@remote_on_c ||= create_remote_user("remote-c.net")
end
def create_remote_user(pod)
def allow_private_key_fetch(user)
allow(DiasporaFederation.callbacks).to receive(:trigger).with(
:fetch_private_key, user.diaspora_handle
) { user.encryption_key }
end
def allow_public_key_fetch(user)
allow(DiasporaFederation.callbacks).to receive(:trigger).with(
:fetch_public_key, user.diaspora_handle
) { OpenSSL::PKey::RSA.new(user.person.serialized_public_key) }
end
def create_undiscovered_user(pod)
FactoryGirl.build(:user).tap do |user|
allow(user).to receive(:person).and_return(
FactoryGirl.create(:person,
profile: FactoryGirl.build(:profile),
serialized_public_key: user.encryption_key.public_key.export,
pod: Pod.find_or_create_by(url: "http://#{pod}"),
diaspora_handle: "#{user.username}@#{pod}")
FactoryGirl.build(:person,
profile: FactoryGirl.build(:profile),
serialized_public_key: user.encryption_key.public_key.export,
pod: Pod.find_or_create_by(url: "http://#{pod}"),
diaspora_handle: "#{user.username}@#{pod}")
)
allow(DiasporaFederation.callbacks).to receive(:trigger).with(
:fetch_private_key, user.diaspora_handle
) { user.encryption_key }
allow(DiasporaFederation.callbacks).to receive(:trigger).with(
:fetch_public_key, user.diaspora_handle
) { OpenSSL::PKey::RSA.new(user.person.serialized_public_key) }
end
end
def expect_person_discovery(undiscovered_user)
allow(Person).to receive(:find_or_fetch_by_identifier).with(any_args).and_call_original
expect(Person).to receive(:find_or_fetch_by_identifier).with(undiscovered_user.diaspora_handle) {
undiscovered_user.person.save!
undiscovered_user.person
}
end
def create_remote_user(pod)
create_undiscovered_user(pod).tap do |user|
user.person.save!
allow_private_key_fetch(user)
allow_public_key_fetch(user)
end
end
......@@ -44,6 +66,14 @@ def create_relayable_entity(entity_name, parent, diaspora_id)
)
end
def create_account_migration_entity(diaspora_id, new_user)
Fabricate(
:account_migration_entity,
author: diaspora_id,
profile: Diaspora::Federation::Entities.build(new_user.profile)
)
end
def generate_payload(entity, remote_user, recipient=nil)
magic_env = DiasporaFederation::Salmon::MagicEnvelope.new(
entity,
......
......@@ -9,7 +9,7 @@ describe "Receive federation messages feature" do
end
let(:sender) { remote_user_on_pod_b }
let(:sender_id) { remote_user_on_pod_b.diaspora_handle }
let(:sender_id) { sender.diaspora_handle }
context "with public receive" do
let(:recipient) { nil }
......@@ -29,6 +29,80 @@ describe "Receive federation messages feature" do
end
end
context "account migration" do
# In case when sender is unknown we should just ignore the migration
# but this depends on https://github.com/diaspora/diaspora_federation/issues/72
# which is low-priority, so we just discover the sender profile in this case.
# But there won't be a spec for that.