protected_attributes 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/.gitignore +17 -0
  2. data/.travis.yml +17 -0
  3. data/Gemfile +7 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +111 -0
  6. data/Rakefile +11 -0
  7. data/lib/action_controller/accessible_params_wrapper.rb +29 -0
  8. data/lib/active_model/mass_assignment_security.rb +353 -0
  9. data/lib/active_model/mass_assignment_security/permission_set.rb +40 -0
  10. data/lib/active_model/mass_assignment_security/sanitizer.rb +74 -0
  11. data/lib/active_record/mass_assignment_security.rb +23 -0
  12. data/lib/active_record/mass_assignment_security/associations.rb +116 -0
  13. data/lib/active_record/mass_assignment_security/attribute_assignment.rb +88 -0
  14. data/lib/active_record/mass_assignment_security/core.rb +27 -0
  15. data/lib/active_record/mass_assignment_security/inheritance.rb +18 -0
  16. data/lib/active_record/mass_assignment_security/nested_attributes.rb +148 -0
  17. data/lib/active_record/mass_assignment_security/persistence.rb +81 -0
  18. data/lib/active_record/mass_assignment_security/reflection.rb +9 -0
  19. data/lib/active_record/mass_assignment_security/relation.rb +47 -0
  20. data/lib/active_record/mass_assignment_security/validations.rb +24 -0
  21. data/lib/protected_attributes.rb +14 -0
  22. data/lib/protected_attributes/railtie.rb +18 -0
  23. data/lib/protected_attributes/version.rb +3 -0
  24. data/protected_attributes.gemspec +26 -0
  25. data/test/abstract_unit.rb +156 -0
  26. data/test/accessible_params_wrapper_test.rb +76 -0
  27. data/test/ar_helper.rb +67 -0
  28. data/test/attribute_sanitization_test.rb +929 -0
  29. data/test/mass_assignment_security/black_list_test.rb +20 -0
  30. data/test/mass_assignment_security/permission_set_test.rb +36 -0
  31. data/test/mass_assignment_security/sanitizer_test.rb +50 -0
  32. data/test/mass_assignment_security/white_list_test.rb +19 -0
  33. data/test/mass_assignment_security_test.rb +118 -0
  34. data/test/models/company.rb +105 -0
  35. data/test/models/keyboard.rb +3 -0
  36. data/test/models/mass_assignment_specific.rb +76 -0
  37. data/test/models/person.rb +82 -0
  38. data/test/models/subscriber.rb +5 -0
  39. data/test/models/task.rb +5 -0
  40. data/test/test_helper.rb +3 -0
  41. metadata +199 -0
@@ -0,0 +1,20 @@
1
+ require "test_helper"
2
+
3
+ class BlackListTest < ActiveModel::TestCase
4
+
5
+ def setup
6
+ @black_list = ActiveModel::MassAssignmentSecurity::BlackList.new
7
+ @included_key = 'admin'
8
+ @black_list += [ @included_key ]
9
+ end
10
+
11
+ test "deny? is true for included items" do
12
+ assert_equal true, @black_list.deny?(@included_key)
13
+ end
14
+
15
+ test "deny? is false for non-included items" do
16
+ assert_equal false, @black_list.deny?('first_name')
17
+ end
18
+
19
+
20
+ end
@@ -0,0 +1,36 @@
1
+ require "test_helper"
2
+
3
+ class PermissionSetTest < ActiveModel::TestCase
4
+
5
+ def setup
6
+ @permission_list = ActiveModel::MassAssignmentSecurity::PermissionSet.new
7
+ end
8
+
9
+ test "+ stringifies added collection values" do
10
+ symbol_collection = [ :admin ]
11
+ new_list = @permission_list += symbol_collection
12
+
13
+ assert new_list.include?('admin'), "did not add collection to #{@permission_list.inspect}}"
14
+ end
15
+
16
+ test "+ compacts added collection values" do
17
+ added_collection = [ nil ]
18
+ new_list = @permission_list + added_collection
19
+ assert_equal new_list, @permission_list, "did not add collection to #{@permission_list.inspect}}"
20
+ end
21
+
22
+ test "include? normalizes multi-parameter keys" do
23
+ multi_param_key = 'admin(1)'
24
+ new_list = @permission_list += [ 'admin' ]
25
+
26
+ assert new_list.include?(multi_param_key), "#{multi_param_key} not found in #{@permission_list.inspect}"
27
+ end
28
+
29
+ test "include? normal keys" do
30
+ normal_key = 'admin'
31
+ new_list = @permission_list += [ normal_key ]
32
+
33
+ assert new_list.include?(normal_key), "#{normal_key} not found in #{@permission_list.inspect}"
34
+ end
35
+
36
+ end
@@ -0,0 +1,50 @@
1
+ require "test_helper"
2
+ require 'active_support/logger'
3
+
4
+ class SanitizerTest < ActiveModel::TestCase
5
+ attr_accessor :logger
6
+
7
+ class Authorizer < ActiveModel::MassAssignmentSecurity::PermissionSet
8
+ def deny?(key)
9
+ ['admin', 'id'].include?(key)
10
+ end
11
+ end
12
+
13
+ def setup
14
+ @logger_sanitizer = ActiveModel::MassAssignmentSecurity::LoggerSanitizer.new(self)
15
+ @strict_sanitizer = ActiveModel::MassAssignmentSecurity::StrictSanitizer.new(self)
16
+ @authorizer = Authorizer.new
17
+ end
18
+
19
+ test "sanitize attributes" do
20
+ original_attributes = { 'first_name' => 'allowed', 'admin' => 'denied' }
21
+ attributes = @logger_sanitizer.sanitize(self.class, original_attributes, @authorizer)
22
+
23
+ assert attributes.key?('first_name'), "Allowed key shouldn't be rejected"
24
+ assert !attributes.key?('admin'), "Denied key should be rejected"
25
+ end
26
+
27
+ test "debug mass assignment removal with LoggerSanitizer" do
28
+ original_attributes = { 'first_name' => 'allowed', 'admin' => 'denied' }
29
+ log = StringIO.new
30
+ self.logger = ActiveSupport::Logger.new(log)
31
+ @logger_sanitizer.sanitize(self.class, original_attributes, @authorizer)
32
+ assert_match(/admin/, log.string, "Should log removed attributes: #{log.string}")
33
+ end
34
+
35
+ test "debug mass assignment removal with StrictSanitizer" do
36
+ original_attributes = { 'first_name' => 'allowed', 'admin' => 'denied' }
37
+ assert_raise ActiveModel::MassAssignmentSecurity::Error do
38
+ @strict_sanitizer.sanitize(self.class, original_attributes, @authorizer)
39
+ end
40
+ end
41
+
42
+ test "mass assignment insensitive attributes" do
43
+ original_attributes = {'id' => 1, 'first_name' => 'allowed'}
44
+
45
+ assert_nothing_raised do
46
+ @strict_sanitizer.sanitize(self.class, original_attributes, @authorizer)
47
+ end
48
+ end
49
+
50
+ end
@@ -0,0 +1,19 @@
1
+ require "test_helper"
2
+
3
+ class WhiteListTest < ActiveModel::TestCase
4
+
5
+ def setup
6
+ @white_list = ActiveModel::MassAssignmentSecurity::WhiteList.new
7
+ @included_key = 'first_name'
8
+ @white_list += [ @included_key ]
9
+ end
10
+
11
+ test "deny? is false for included items" do
12
+ assert_equal false, @white_list.deny?(@included_key)
13
+ end
14
+
15
+ test "deny? is true for non-included items" do
16
+ assert_equal true, @white_list.deny?('admin')
17
+ end
18
+
19
+ end
@@ -0,0 +1,118 @@
1
+ require 'test_helper'
2
+ require 'active_model/mass_assignment_security'
3
+ require 'models/mass_assignment_specific'
4
+
5
+ class CustomSanitizer < ActiveModel::MassAssignmentSecurity::Sanitizer
6
+
7
+ def process_removed_attributes(klass, attrs)
8
+ raise StandardError
9
+ end
10
+
11
+ end
12
+
13
+ class MassAssignmentSecurityTest < ActiveModel::TestCase
14
+ def test_attribute_protection
15
+ user = User.new
16
+ expected = { "name" => "John Smith", "email" => "john@smith.com" }
17
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("admin" => true))
18
+ assert_equal expected, sanitized
19
+ end
20
+
21
+ def test_attribute_protection_when_role_is_nil
22
+ user = User.new
23
+ expected = { "name" => "John Smith", "email" => "john@smith.com" }
24
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("admin" => true), nil)
25
+ assert_equal expected, sanitized
26
+ end
27
+
28
+ def test_only_moderator_role_attribute_accessible
29
+ user = SpecialUser.new
30
+ expected = { "name" => "John Smith", "email" => "john@smith.com" }
31
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("admin" => true), :moderator)
32
+ assert_equal expected, sanitized
33
+
34
+ sanitized = user.sanitize_for_mass_assignment({ "name" => "John Smith", "email" => "john@smith.com", "admin" => true })
35
+ assert_equal({}, sanitized)
36
+ end
37
+
38
+ def test_attributes_accessible
39
+ user = Person.new
40
+ expected = { "name" => "John Smith", "email" => "john@smith.com" }
41
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("admin" => true))
42
+ assert_equal expected, sanitized
43
+ end
44
+
45
+ def test_attributes_accessible_with_admin_role
46
+ user = Person.new
47
+ expected = { "name" => "John Smith", "email" => "john@smith.com", "admin" => true }
48
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("super_powers" => true), :admin)
49
+ assert_equal expected, sanitized
50
+ end
51
+
52
+ def test_attributes_accessible_with_roles_given_as_array
53
+ user = Account.new
54
+ expected = { "name" => "John Smith", "email" => "john@smith.com" }
55
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("admin" => true))
56
+ assert_equal expected, sanitized
57
+ end
58
+
59
+ def test_attributes_accessible_with_admin_role_when_roles_given_as_array
60
+ user = Account.new
61
+ expected = { "name" => "John Smith", "email" => "john@smith.com", "admin" => true }
62
+ sanitized = user.sanitize_for_mass_assignment(expected.merge("super_powers" => true), :admin)
63
+ assert_equal expected, sanitized
64
+ end
65
+
66
+ def test_attributes_protected_by_default
67
+ firm = Firm.new
68
+ expected = { }
69
+ sanitized = firm.sanitize_for_mass_assignment({ "type" => "Client" })
70
+ assert_equal expected, sanitized
71
+ end
72
+
73
+ def test_mass_assignment_protection_inheritance
74
+ assert SpecialLoosePerson.accessible_attributes.blank?
75
+ assert_equal Set.new(['credit_rating', 'administrator']), SpecialLoosePerson.protected_attributes
76
+
77
+ assert SpecialLoosePerson.accessible_attributes.blank?
78
+ assert_equal Set.new(['credit_rating']), SpecialLoosePerson.protected_attributes(:admin)
79
+
80
+ assert LooseDescendant.accessible_attributes.blank?
81
+ assert_equal Set.new(['credit_rating', 'administrator', 'phone_number']), LooseDescendant.protected_attributes
82
+
83
+ assert LooseDescendantSecond.accessible_attributes.blank?
84
+ assert_equal Set.new(['credit_rating', 'administrator', 'phone_number', 'name']), LooseDescendantSecond.protected_attributes,
85
+ 'Running attr_protected twice in one class should merge the protections'
86
+
87
+ assert((SpecialTightPerson.protected_attributes - SpecialTightPerson.attributes_protected_by_default).blank?)
88
+ assert_equal Set.new(['name', 'address']), SpecialTightPerson.accessible_attributes
89
+
90
+ assert((SpecialTightPerson.protected_attributes(:admin) - SpecialTightPerson.attributes_protected_by_default).blank?)
91
+ assert_equal Set.new(['name', 'address', 'admin']), SpecialTightPerson.accessible_attributes(:admin)
92
+
93
+ assert((TightDescendant.protected_attributes - TightDescendant.attributes_protected_by_default).blank?)
94
+ assert_equal Set.new(['name', 'address', 'phone_number']), TightDescendant.accessible_attributes
95
+
96
+ assert((TightDescendant.protected_attributes(:admin) - TightDescendant.attributes_protected_by_default).blank?)
97
+ assert_equal Set.new(['name', 'address', 'admin', 'super_powers']), TightDescendant.accessible_attributes(:admin)
98
+ end
99
+
100
+ def test_mass_assignment_multiparameter_protector
101
+ task = Task.new
102
+ attributes = { "starting(1i)" => "2004", "starting(2i)" => "6", "starting(3i)" => "24" }
103
+ sanitized = task.sanitize_for_mass_assignment(attributes)
104
+ assert_equal sanitized, { }
105
+ end
106
+
107
+ def test_custom_sanitizer
108
+ old_sanitizer = User._mass_assignment_sanitizer
109
+
110
+ user = User.new
111
+ User.mass_assignment_sanitizer = CustomSanitizer.new
112
+ assert_raise StandardError do
113
+ user.sanitize_for_mass_assignment("admin" => true)
114
+ end
115
+ ensure
116
+ User.mass_assignment_sanitizer = old_sanitizer
117
+ end
118
+ end
@@ -0,0 +1,105 @@
1
+ class AbstractCompany < ActiveRecord::Base
2
+ self.abstract_class = true
3
+ end
4
+
5
+ class Company < AbstractCompany
6
+ attr_protected :rating
7
+ self.sequence_name = :companies_nonstd_seq
8
+
9
+ validates_presence_of :name
10
+
11
+ has_one :dummy_account, :foreign_key => "firm_id", :class_name => "Account"
12
+ has_many :contracts
13
+ has_many :developers, :through => :contracts
14
+
15
+ def arbitrary_method
16
+ "I am Jack's profound disappointment"
17
+ end
18
+
19
+ private
20
+
21
+ def private_method
22
+ "I am Jack's innermost fears and aspirations"
23
+ end
24
+ end
25
+
26
+ class Firm < Company
27
+ ActiveSupport::Deprecation.silence do
28
+ has_many :clients, -> { order "id" }, :dependent => :destroy, :counter_sql =>
29
+ "SELECT COUNT(*) FROM companies WHERE firm_id = 1 " +
30
+ "AND (#{QUOTED_TYPE} = 'Client' OR #{QUOTED_TYPE} = 'SpecialClient' OR #{QUOTED_TYPE} = 'VerySpecialClient' )",
31
+ :before_remove => :log_before_remove,
32
+ :after_remove => :log_after_remove
33
+ end
34
+ has_many :unsorted_clients, :class_name => "Client"
35
+ has_many :unsorted_clients_with_symbol, :class_name => :Client
36
+ has_many :clients_sorted_desc, -> { order "id DESC" }, :class_name => "Client"
37
+ has_many :clients_of_firm, -> { order "id" }, :foreign_key => "client_of", :class_name => "Client"
38
+ has_many :clients_ordered_by_name, -> { order "name" }, :class_name => "Client"
39
+ has_many :unvalidated_clients_of_firm, :foreign_key => "client_of", :class_name => "Client", :validate => false
40
+ has_many :dependent_clients_of_firm, -> { order "id" }, :foreign_key => "client_of", :class_name => "Client", :dependent => :destroy
41
+ has_many :exclusively_dependent_clients_of_firm, -> { order "id" }, :foreign_key => "client_of", :class_name => "Client", :dependent => :delete_all
42
+ has_many :limited_clients, -> { limit 1 }, :class_name => "Client"
43
+ has_many :clients_with_interpolated_conditions, ->(firm) { where "rating > #{firm.rating}" }, :class_name => "Client"
44
+ has_many :clients_like_ms, -> { where("name = 'Microsoft'").order("id") }, :class_name => "Client"
45
+ has_many :clients_like_ms_with_hash_conditions, -> { where(:name => 'Microsoft').order("id") }, :class_name => "Client"
46
+ ActiveSupport::Deprecation.silence do
47
+ has_many :clients_using_sql, :class_name => "Client", :finder_sql => proc { "SELECT * FROM companies WHERE client_of = #{id}" }
48
+ has_many :clients_using_counter_sql, :class_name => "Client",
49
+ :finder_sql => proc { "SELECT * FROM companies WHERE client_of = #{id} " },
50
+ :counter_sql => proc { "SELECT COUNT(*) FROM companies WHERE client_of = #{id}" }
51
+ has_many :clients_using_zero_counter_sql, :class_name => "Client",
52
+ :finder_sql => proc { "SELECT * FROM companies WHERE client_of = #{id}" },
53
+ :counter_sql => proc { "SELECT 0 FROM companies WHERE client_of = #{id}" }
54
+ has_many :no_clients_using_counter_sql, :class_name => "Client",
55
+ :finder_sql => 'SELECT * FROM companies WHERE client_of = 1000',
56
+ :counter_sql => 'SELECT COUNT(*) FROM companies WHERE client_of = 1000'
57
+ has_many :clients_using_finder_sql, :class_name => "Client", :finder_sql => 'SELECT * FROM companies WHERE 1=1'
58
+ end
59
+ has_many :plain_clients, :class_name => 'Client'
60
+ has_many :readonly_clients, -> { readonly }, :class_name => 'Client'
61
+ has_many :clients_using_primary_key, :class_name => 'Client',
62
+ :primary_key => 'name', :foreign_key => 'firm_name'
63
+ has_many :clients_using_primary_key_with_delete_all, :class_name => 'Client',
64
+ :primary_key => 'name', :foreign_key => 'firm_name', :dependent => :delete_all
65
+ has_many :clients_grouped_by_firm_id, -> { group("firm_id").select("firm_id") }, :class_name => "Client"
66
+ has_many :clients_grouped_by_name, -> { group("name").select("name") }, :class_name => "Client"
67
+
68
+ has_one :account, :foreign_key => "firm_id", :dependent => :destroy, :validate => true
69
+ has_one :unvalidated_account, :foreign_key => "firm_id", :class_name => 'Account', :validate => false
70
+ has_one :account_with_select, -> { select("id, firm_id") }, :foreign_key => "firm_id", :class_name=>'Account'
71
+ has_one :readonly_account, -> { readonly }, :foreign_key => "firm_id", :class_name => "Account"
72
+ # added order by id as in fixtures there are two accounts for Rails Core
73
+ # Oracle tests were failing because of that as the second fixture was selected
74
+ has_one :account_using_primary_key, -> { order('id') }, :primary_key => "firm_id", :class_name => "Account"
75
+ has_one :account_using_foreign_and_primary_keys, :foreign_key => "firm_name", :primary_key => "name", :class_name => "Account"
76
+ has_one :deletable_account, :foreign_key => "firm_id", :class_name => "Account", :dependent => :delete
77
+
78
+ has_one :account_limit_500_with_hash_conditions, -> { where :credit_limit => 500 }, :foreign_key => "firm_id", :class_name => "Account"
79
+
80
+ has_one :unautosaved_account, :foreign_key => "firm_id", :class_name => 'Account', :autosave => false
81
+ has_many :accounts
82
+ has_many :unautosaved_accounts, :foreign_key => "firm_id", :class_name => 'Account', :autosave => false
83
+
84
+ has_many :association_with_references, -> { references(:foo) }, :class_name => 'Client'
85
+
86
+ def log
87
+ @log ||= []
88
+ end
89
+
90
+ private
91
+ def log_before_remove(record)
92
+ log << "before_remove#{record.id}"
93
+ end
94
+
95
+ def log_after_remove(record)
96
+ log << "after_remove#{record.id}"
97
+ end
98
+ end
99
+
100
+ class Corporation < Company
101
+ attr_accessible :type, :name, :description
102
+ end
103
+
104
+ class SpecialCorporation < Corporation
105
+ end
@@ -0,0 +1,3 @@
1
+ class Keyboard < ActiveRecord::Base
2
+ self.primary_key = 'key_number'
3
+ end
@@ -0,0 +1,76 @@
1
+ class User
2
+ include ActiveModel::MassAssignmentSecurity
3
+ attr_protected :admin
4
+
5
+ public :sanitize_for_mass_assignment
6
+ end
7
+
8
+ class SpecialUser
9
+ include ActiveModel::MassAssignmentSecurity
10
+ attr_accessible :name, :email, :as => :moderator
11
+
12
+ public :sanitize_for_mass_assignment
13
+ end
14
+
15
+ class Person
16
+ include ActiveModel::MassAssignmentSecurity
17
+ attr_accessible :name, :email
18
+ attr_accessible :name, :email, :admin, :as => :admin
19
+
20
+ public :sanitize_for_mass_assignment
21
+ end
22
+
23
+ class Account
24
+ include ActiveModel::MassAssignmentSecurity
25
+ attr_accessible :name, :email, :as => [:default, :admin]
26
+ attr_accessible :admin, :as => :admin
27
+
28
+ public :sanitize_for_mass_assignment
29
+ end
30
+
31
+ class Firm
32
+ include ActiveModel::MassAssignmentSecurity
33
+
34
+ public :sanitize_for_mass_assignment
35
+
36
+ def self.attributes_protected_by_default
37
+ ["type"]
38
+ end
39
+ end
40
+
41
+ class Task
42
+ include ActiveModel::MassAssignmentSecurity
43
+ attr_protected :starting
44
+
45
+ public :sanitize_for_mass_assignment
46
+ end
47
+
48
+ class SpecialLoosePerson
49
+ include ActiveModel::MassAssignmentSecurity
50
+ attr_protected :credit_rating, :administrator
51
+ attr_protected :credit_rating, :as => :admin
52
+ end
53
+
54
+ class LooseDescendant < SpecialLoosePerson
55
+ attr_protected :phone_number
56
+ end
57
+
58
+ class LooseDescendantSecond< SpecialLoosePerson
59
+ attr_protected :phone_number
60
+ attr_protected :name
61
+ end
62
+
63
+ class SpecialTightPerson
64
+ include ActiveModel::MassAssignmentSecurity
65
+ attr_accessible :name, :address
66
+ attr_accessible :name, :address, :admin, :as => :admin
67
+
68
+ def self.attributes_protected_by_default
69
+ ["mobile_number"]
70
+ end
71
+ end
72
+
73
+ class TightDescendant < SpecialTightPerson
74
+ attr_accessible :phone_number
75
+ attr_accessible :super_powers, :as => :admin
76
+ end
@@ -0,0 +1,82 @@
1
+ class Person < ActiveRecord::Base
2
+ has_many :readers
3
+ has_many :secure_readers
4
+ has_one :reader
5
+
6
+ has_many :posts, :through => :readers
7
+ has_many :secure_posts, :through => :secure_readers
8
+ has_many :posts_with_no_comments, -> { includes(:comments).where('comments.id is null').references(:comments) },
9
+ :through => :readers, :source => :post
10
+
11
+ has_many :followers, foreign_key: 'friend_id', class_name: 'Friendship'
12
+
13
+ has_many :references
14
+ has_many :bad_references
15
+ has_many :fixed_bad_references, -> { where :favourite => true }, :class_name => 'BadReference'
16
+ has_one :favourite_reference, -> { where 'favourite=?', true }, :class_name => 'Reference'
17
+ has_many :posts_with_comments_sorted_by_comment_id, -> { includes(:comments).order('comments.id') }, :through => :readers, :source => :post
18
+
19
+ has_many :jobs, :through => :references
20
+ has_many :jobs_with_dependent_destroy, :source => :job, :through => :references, :dependent => :destroy
21
+ has_many :jobs_with_dependent_delete_all, :source => :job, :through => :references, :dependent => :delete_all
22
+ has_many :jobs_with_dependent_nullify, :source => :job, :through => :references, :dependent => :nullify
23
+
24
+ belongs_to :primary_contact, :class_name => 'Person'
25
+ has_many :agents, :class_name => 'Person', :foreign_key => 'primary_contact_id'
26
+ has_many :agents_of_agents, :through => :agents, :source => :agents
27
+ belongs_to :number1_fan, :class_name => 'Person'
28
+
29
+ has_many :agents_posts, :through => :agents, :source => :posts
30
+ has_many :agents_posts_authors, :through => :agents_posts, :source => :author
31
+
32
+ scope :males, -> { where(:gender => 'M') }
33
+ scope :females, -> { where(:gender => 'F') }
34
+ end
35
+
36
+ class LoosePerson < ActiveRecord::Base
37
+ self.table_name = 'people'
38
+ self.abstract_class = true
39
+
40
+ attr_protected :comments, :best_friend_id, :best_friend_of_id
41
+ attr_protected :as => :admin
42
+
43
+ has_one :best_friend, :class_name => 'LoosePerson', :foreign_key => :best_friend_id
44
+ belongs_to :best_friend_of, :class_name => 'LoosePerson', :foreign_key => :best_friend_of_id
45
+ has_many :best_friends, :class_name => 'LoosePerson', :foreign_key => :best_friend_id
46
+
47
+ accepts_nested_attributes_for :best_friend, :best_friend_of, :best_friends
48
+ end
49
+
50
+ class TightPerson < ActiveRecord::Base
51
+ self.table_name = 'people'
52
+
53
+ attr_accessible :first_name, :gender
54
+ attr_accessible :first_name, :gender, :comments, :as => :admin
55
+ attr_accessible :best_friend_attributes, :best_friend_of_attributes, :best_friends_attributes
56
+ attr_accessible :best_friend_attributes, :best_friend_of_attributes, :best_friends_attributes, :as => :admin
57
+
58
+ has_one :best_friend, :class_name => 'TightPerson', :foreign_key => :best_friend_id
59
+ belongs_to :best_friend_of, :class_name => 'TightPerson', :foreign_key => :best_friend_of_id
60
+ has_many :best_friends, :class_name => 'TightPerson', :foreign_key => :best_friend_id
61
+
62
+ accepts_nested_attributes_for :best_friend, :best_friend_of, :best_friends
63
+ end
64
+
65
+ class NestedPerson < ActiveRecord::Base
66
+ self.table_name = 'people'
67
+
68
+ attr_accessible :first_name, :best_friend_first_name, :best_friend_attributes
69
+ attr_accessible :first_name, :gender, :comments, :as => :admin
70
+ attr_accessible :best_friend_attributes, :best_friend_first_name, :as => :admin
71
+
72
+ has_one :best_friend, :class_name => 'NestedPerson', :foreign_key => :best_friend_id
73
+ accepts_nested_attributes_for :best_friend, :update_only => true
74
+
75
+ def comments=(new_comments)
76
+ raise RuntimeError
77
+ end
78
+
79
+ def best_friend_first_name=(new_name)
80
+ assign_attributes({ :best_friend_attributes => { :first_name => new_name } })
81
+ end
82
+ end