uhees-declarative_authorization 0.3.1 → 0.3.2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/CHANGELOG CHANGED
@@ -1,3 +1,9 @@
1
+ * Change Support: Suggestion grouping, sort by affected users [sb]
2
+
3
+ * Changed context derived from objects to #class.name.tableize to fix STI [sb]
4
+
5
+ * Simplified controller authorization with filter_resource_access [sb]
6
+
1
7
  * Allow passing explicit context in addition to object in permitted_to? [Olly Lylo, sb]
2
8
 
3
9
  * Change Supporter: suggest changes to authorization rules [sb]
@@ -79,12 +79,26 @@ generated yourself or at http://www.tzi.org/~sbartsch/declarative_authorization
79
79
 
80
80
  == Controller
81
81
 
82
- If authentication is in place, enabling user-specific access control may be
83
- as simple as one call to filter_access_to :all which simply requires the
84
- according privileges for present actions. E.g. the privilege index_users is
85
- required for action index. This works as a first default configuration
86
- for RESTful controllers, with these privileges easily handled in the
87
- authorization configuration, which will be described below.
82
+ If authentication is in place, there are two ways to enable user-specific
83
+ access control on controller actions. For resource controllers, which more
84
+ or less follow the CRUD pattern, +filter_resource_access+ is the simplest
85
+ approach. It sets up instance variables in before filters and calls
86
+ filter_access_to with the appropriate parameters to protect the CRUD methods.
87
+
88
+ class EmployeesController < ApplicationController
89
+ filter_resource_access
90
+ ...
91
+ end
92
+
93
+ See Authorization::AuthorizationInController::ClassMethods for options on
94
+ nested resources and custom member and collection actions.
95
+
96
+ If you prefer less magic or your controller has no resemblance with the resource
97
+ controllers, directly calling filter_access_to may be the better option. Examples
98
+ are given in the following. E.g. the privilege index users is required for
99
+ action index. This works as a first default configuration for RESTful
100
+ controllers, with these privileges easily handled in the authorization
101
+ configuration, which will be described below.
88
102
 
89
103
  class EmployeesController < ApplicationController
90
104
  filter_access_to :all
@@ -456,7 +470,7 @@ and Ubuntu) and has only been tested under Linux.
456
470
 
457
471
  = Help and Contact
458
472
 
459
- We have an issue tracker[http://stffn.lighthouseapp.com/projects/20733-declarative_authorization]
473
+ We have an issue tracker[http://github.com/stffn/declarative_authorization/issues]
460
474
  for bugs and feature requests as well as a
461
475
  Google Group[http://groups.google.com/group/declarative_authorization] for
462
476
  discussions on the usage of the plugin. You are very welcome to contribute.
@@ -475,11 +489,14 @@ sbartsch at tzi.org
475
489
  Thanks to
476
490
  * Eike Carls
477
491
  * Erik Dahlstrand
492
+ * Jeroen van Dijk
478
493
  * Jeremy Friesen
479
494
  * Brian Langenfeld
495
+ * Georg Ledermann
480
496
  * Geoff Longman
481
497
  * Olly Lylo
482
498
  * Mark Mansour
499
+ * Thomas Maurer
483
500
  * Mike Vincent
484
501
 
485
502
 
@@ -35,7 +35,10 @@ class AuthorizationRulesController < ApplicationController
35
35
  @users.sort! {|a, b| a.login <=> b.login }
36
36
 
37
37
  @privileges = authorization_engine.auth_rules.collect {|rule| rule.privileges.to_a}.flatten.uniq
38
- @privileges = @privileges.collect {|priv| Authorization::DevelopmentSupport::AnalyzerEngine::Privilege.for_sym(priv, authorization_engine).descendants.map(&:to_sym) }.flatten.uniq
38
+ @privileges = @privileges.collect do |priv|
39
+ priv = Authorization::DevelopmentSupport::AnalyzerEngine::Privilege.for_sym(priv, authorization_engine)
40
+ (priv.descendants + priv.ancestors).map(&:to_sym)
41
+ end.flatten.uniq
39
42
  @privileges.sort_by {|priv| priv.to_s}
40
43
  @privilege = params[:privilege].to_sym rescue @privileges.first
41
44
  @contexts = authorization_engine.auth_rules.collect {|rule| rule.contexts.to_a}.flatten.uniq
@@ -64,19 +67,40 @@ class AuthorizationRulesController < ApplicationController
64
67
  deserialize_changes(spec).flatten
65
68
  end
66
69
 
67
- users_keys = users_permission.keys
68
70
  analyzer = Authorization::DevelopmentSupport::ChangeSupporter.new(authorization_engine)
69
71
 
70
72
  privilege = params[:privilege].to_sym
71
73
  context = params[:context].to_sym
74
+ all_users = User.all
72
75
  @context = context
73
- @approaches = analyzer.find_approaches_for(:users => users_keys, :prohibited_actions => prohibited_actions) do
76
+ @approaches = analyzer.find_approaches_for(:users => all_users, :prohibited_actions => prohibited_actions) do
74
77
  users.each_with_index do |user, idx|
75
- args = [privilege, {:context => context, :user => user}]
76
- assert(users_permission[users_keys[idx]] ? permit?(*args) : !permit?(*args))
78
+ unless users_permission[all_users[idx]].nil?
79
+ args = [privilege, {:context => context, :user => user}]
80
+ assert(users_permission[all_users[idx]] ? permit?(*args) : !permit?(*args))
81
+ end
77
82
  end
78
83
  end
79
84
 
85
+ @affected_users = @approaches.each_with_object({}) do |approach, memo|
86
+ memo[approach] = approach.affected_users(authorization_engine, all_users, privilege, context).length
87
+ end
88
+ max_affected_users = @affected_users.values.max
89
+ if params[:affected_users]
90
+ @approaches = @approaches.sort_by do |approach|
91
+ affected_users_count = @affected_users[approach]
92
+ if params[:affected_users] == "many"
93
+ #approach.weight.to_f / [affected_users_count, 0.1].min
94
+ approach.weight + (max_affected_users - affected_users_count) * 10
95
+ else
96
+ #approach.weight * affected_users_count
97
+ approach.weight + affected_users_count * 10
98
+ end
99
+ end
100
+ end
101
+
102
+ @grouped_approaches = analyzer.group_approaches(@approaches)
103
+
80
104
  respond_to do |format|
81
105
  format.js do
82
106
  render :partial => 'suggestions'
@@ -46,7 +46,7 @@ module AuthorizationRulesHelper
46
46
 
47
47
  def navigation
48
48
  link_to("Rules", authorization_rules_path) << ' | ' <<
49
- link_to("Change Supporter", change_authorization_rules_path) << ' | ' <<
49
+ link_to("Change Support", change_authorization_rules_path) << ' | ' <<
50
50
  link_to("Graphical view", graph_authorization_rules_path) << ' | ' <<
51
51
  link_to("Usages", authorization_usages_path) #<< ' | ' <<
52
52
  # 'Edit | ' <<
@@ -118,8 +118,8 @@ module AuthorizationRulesHelper
118
118
 
119
119
  def prohibit_link (step, text, title, options)
120
120
  options[:with_removal] ?
121
- ' ' + link_to_function("[x]", "prohibit_action('#{serialize_action(step)}', '#{text}')",
122
- :class => 'unimportant', :title => title) :
121
+ link_to_function("[x]", "prohibit_action('#{serialize_action(step)}', '#{text}')",
122
+ :class => 'prohibit', :title => title) :
123
123
  ''
124
124
  end
125
125
 
@@ -149,6 +149,10 @@ module AuthorizationRulesHelper
149
149
  @changes && @changes[args[0]] && @changes[args[0]].include?(args[1..-1])
150
150
  end
151
151
 
152
+ def affected_users_count (approach)
153
+ @affected_users[approach]
154
+ end
155
+
152
156
  def auth_usage_info_classes (auth_info)
153
157
  classes = []
154
158
  if auth_info[:controller_permissions]
@@ -1,5 +1,5 @@
1
1
  <form>
2
- <h2>1. Choose permission to change</h2>
2
+ <h2>Which permission to change?</h2>
3
3
  <p class="action-options">
4
4
  <label>Privilege</label>
5
5
  <%= select_tag :privilege, options_for_select(@privileges.map(&:to_s).sort, @privilege.to_s) %>
@@ -7,10 +7,19 @@
7
7
  <label>On</label>
8
8
  <%= select_tag :context, options_for_select(@contexts.map(&:to_s).sort, @context.to_s) %>
9
9
  <br/>
10
- <%= link_to_function "Current permissions", "show_current_permissions()", :class => 'unimportant' %>
10
+ <label></label>
11
+ <%= link_to_function "Show current permissions", "show_current_permissions()", :class => 'unimportant' %>
12
+ <br/><br/>
13
+ How many users should be <strong>affected</strong>?
14
+ <br/>
15
+ <label></label>
16
+ <%= radio_button_tag :affected_users, :few, params[:affected_users] == 'few' %>
17
+ <label class="inline">A <strong>few</strong> users</label>
18
+ <%= radio_button_tag :affected_users, :many, params[:affected_users] == 'many' %>
19
+ <label class="inline"><strong>Many</strong> users</label>
11
20
  </p>
12
21
 
13
- <h2>2. Whose permission should be changed?</h2>
22
+ <h2>Whose permission should be changed?</h2>
14
23
  <table class="change-options">
15
24
  <thead>
16
25
  <tr>
@@ -32,6 +32,6 @@
32
32
  }
33
33
  <% end %>
34
34
  <div id="graph-container" style="display:none">
35
- <%= link_to_function "Hide", "$('graph-container').hide()" %><br/>
36
- <object id="graph" data="" type="image/svg+xml" style="max-width:100%"/>
35
+ <%= link_to_function "Hide", "$('graph-container').hide()", :class => 'important' %><br/>
36
+ <object id="graph" data="" type="image/svg+xml" style="max-width:100%;margin-top: 0.5em"/>
37
37
  </div>
@@ -4,21 +4,45 @@
4
4
  <% if @approaches.first.changes.empty? %>
5
5
  <p>No changes necessary.</p>
6
6
  <% else %>
7
- <p>Suggested changes (<%= link_to_function "show", "show_suggest_graph('#{serialize_changes(@approaches.first)}', '#{serialize_relevant_roles(@approaches.first)}', '#{@context}', relevant_user_ids())" %>):</p>
8
- <%= render "suggestion", :object => @approaches.first %>
7
+ <p class="unimportant">
8
+ <%= pluralize(@approaches.length, 'approach') %> in
9
+ <%= pluralize(@grouped_approaches.length, 'group') %>
10
+ <%= params[:affected_users] ? "– #{params[:affected_users] == 'few' ? "fewer" : "most"} affected users first" : "" %>
11
+ </p>
12
+ <ul>
13
+ <% @grouped_approaches.each_with_index do |grouped_approach, group_index| %>
14
+ <% ([grouped_approach.approach] + grouped_approach.similar_approaches).each_with_index do |approach, index| %>
15
+ <li <%= (group_index < 3 || params[:show_all]) && index == 0 ? '' : 'style="display: none"' %> class="<%= index == 0 ? 'primary' : "secondary" %> group-<%= group_index %>">
16
+ <!--<span class="ord"><%= (index + 1) %></span>-->
17
+ <span class="unimportant">Affected users</span> <strong><%= affected_users_count(approach) %></strong> &nbsp;
18
+ <span class="unimportant">Complexity</span> <strong><%= approach.weight %></strong> &nbsp;
19
+ <%= link_to_function "Diagram", "show_suggest_graph('#{serialize_changes(approach)}', '#{serialize_relevant_roles(approach)}', '#{@context}', relevant_user_ids())", :class => "show-approach" %>
20
+ <ul>
21
+ <% approach.changes.each do |action| %>
22
+ <% (action.to_a[0].is_a?(Enumerable) ? action.to_a : [action.to_a]).each do |step| %>
23
+ <li>
24
+ <%= describe_step(step.to_a, :with_removal => true) %>
25
+ </li>
26
+ <% end %>
27
+ <% end %>
28
+ </ul>
29
+ <% if index == 0 %>
30
+ <% if grouped_approach.similar_approaches.empty? %>
31
+ <span class="unimportant show-others-in-group">No further suggestions in this group</span>
32
+ <% else %>
33
+ <%= link_to_function "Show further #{pluralize grouped_approach.similar_approaches.length, "suggestion"} in this group", "show_group_approaches(#{group_index})", :class => "show-others-in-group" %>
34
+ <% end %>
35
+ <% end %>
36
+ <%= link_to_function "Hide further suggestions", "hide_group_approaches(#{group_index})" if index > 0 and index == grouped_approach.similar_approaches.length %>
37
+ </li>
38
+ <% if index == 0 and group_index == 2 and @grouped_approaches.length > 3 %>
39
+ <li <%= !params[:show_all] ? '' : 'style="display: none"' %>><%= link_to_function "Show further #{pluralize(@grouped_approaches.length - 3, 'group')}", "show_all_groups(); $(this).up().hide()" %></li>
40
+ <% end %>
41
+ <% end %>
42
+ <% end %>
43
+ </ul>
44
+
9
45
  <% end %>
10
46
  <% else %>
11
47
  <p><strong>No approach found.</strong></p>
12
48
  <% end %>
13
-
14
- <% if @approaches.length > 1 %>
15
- <p <%= !params[:show_all] ? '' : 'style="display: none"' %>><a href="#" onclick="$(this).up().hide();$('more-suggestions').show();return false">Show other <%= pluralize(@approaches.length - 1, 'approach') %></a></p>
16
- <div id="more-suggestions" <%= params[:show_all] ? '' : 'style="display: none"' %>>
17
- <% @approaches[1..-1].each_with_index do |approach, index| %>
18
- <p>
19
- <%= (index + 2).ordinalize %> best approach (<%= pluralize(approach.changes.length, 'step') %>, <%= link_to_function "show", "show_suggest_graph('#{serialize_changes(approach)}', '#{serialize_relevant_roles(approach)}', '#{@context}', relevant_user_ids())" %>)
20
- <%= render "suggestion", :object => approach %>
21
- </p>
22
- <% end %>
23
- </div>
24
- <% end %>
@@ -4,12 +4,13 @@
4
4
  <%= link_to_function "Hide", "$(this).up().hide()", :class => 'important' %>
5
5
  <%= link_to_function "Toggle stacked roles", "toggle_graph_params('suggest-graph', 'stacked_roles');" %>
6
6
  <%= link_to_function "Toggle only users' roles", "toggle_graph_params('suggest-graph', 'only_relevant_roles');" %><br/>
7
- <object id="suggest-graph" data="" type="image/svg+xml" style="max-width: 98%;max-height: 95%"/>
7
+ <object id="suggest-graph" data="" type="image/svg+xml" style="max-width: 98%;max-height: 95%;margin-top: 0.5em"/>
8
8
  </div>
9
9
  <%= render 'show_graph' %>
10
10
 
11
11
  <style type="text/css">
12
12
  .action-options label { display: block; float: left; width: 7em; padding-bottom: 0.5em }
13
+ .action-options label.inline { display: inline; float: none }
13
14
  .action-options select { float: left; }
14
15
  .action-options br { clear: both; }
15
16
  .action-options { margin-bottom: 2em }
@@ -23,21 +24,33 @@
23
24
  .submit { margin-top: 0 }
24
25
  .submit input { font-weight: bold; font-size: 120% }
25
26
  #suggest-result {
26
- position: absolute; left: 60%; right: 10px;
27
- border-left: 2px solid grey;
28
- padding-left: 1em;
27
+ position: absolute; left: 55%; right: 10px;
28
+ border-left: 1px solid grey;
29
+ padding-left: 2em;
29
30
  z-index: 10;
30
31
  }
32
+ #suggest-result>ul { padding-left: 0 }
33
+ #suggest-result>ul>li { list-style: none; margin-bottom: 1em }
34
+ #suggest-result>ul>li.secondary { padding-left: 2em }
35
+ #suggest-result li { padding-left: 0 }
36
+ #suggest-result ul ul { padding-left: 2em }
37
+ #suggest-result .ord { float: left; display: block; width: 2em; font-weight: bold; color: grey }
38
+ .show-approach, .show-others-in-group { visibility: hidden }
39
+ .prohibit { text-decoration: none; visibility: hidden }
40
+ li:hover .show-approach, li:hover .prohibit,
41
+ li:hover .show-others-in-group { visibility: visible }
31
42
  #suggest-graph-container {
32
- background: white; border:1px solid #ccc;
43
+ background: white; border:1px solid grey;
33
44
  position:fixed; z-index: 20;
34
- left:10%; bottom: 10%; right: 10%; top: 10%
45
+ left:10%; bottom: 10%; right: 10%; top: 10%;
46
+ padding: 0.5em
35
47
  }
36
48
  #graph-container {
37
- background: white; margin: 1em; border:1px solid #ccc;
49
+ background: white; margin: 1em; border:1px solid grey; padding: 0.5em;
38
50
  max-width:50%; position:fixed; z-index: 20; right:0;
39
51
  }
40
- .unimportant, .remove { color: grey }
52
+ .unimportant, .remove, .prohibit { color: grey }
53
+ .important { font-weight: bold }
41
54
  .remove { cursor: pointer }
42
55
  </style>
43
56
  <% javascript_tag do %>
@@ -72,8 +85,9 @@
72
85
  function install_change_observers () {
73
86
  $$('#change select').each(function (el) {
74
87
  el.observe('change', function (event) {
88
+ var form = $('change').down('form');
75
89
  new Ajax.Updater({success: 'change'}, '<%= url_for %>', {
76
- parameters: { context: $F('context'), privilege: $F('privilege') },
90
+ parameters: Form.serializeElements(form.select('select').concat(form.getInputs('radio', 'affected_users')), true),
77
91
  method: 'get',
78
92
  onComplete: function () {
79
93
  install_change_observers();
@@ -98,6 +112,20 @@
98
112
  show_graph($F('privilege'), $F('context')/*, relevant_user_ids()*/);
99
113
  }
100
114
 
115
+ function show_all_groups () {
116
+ $$('#suggest-result>ul>li.primary').invoke("show");
117
+ }
118
+
119
+ function show_group_approaches (group_no) {
120
+ $$('#suggest-result>ul>li.secondary.group-' + group_no).invoke("show");
121
+ $$('#suggest-result>ul>li.primary.group-' + group_no + ' a.show-others-in-group').invoke("hide");
122
+ }
123
+
124
+ function hide_group_approaches (group_no) {
125
+ $$('#suggest-result>ul>li.secondary.group-' + group_no).invoke("hide");
126
+ $$('#suggest-result>ul>li.primary.group-' + group_no + ' a.show-others-in-group').invoke("show");
127
+ }
128
+
101
129
  function relevant_user_ids () {
102
130
  return $$('#change .user_id').collect(function (el) {
103
131
  return el.innerHTML;
@@ -33,10 +33,10 @@ module Authorization
33
33
  Thread.current["current_user"] = user
34
34
  end
35
35
 
36
- @@ignore_access_control = false
37
36
  # For use in test cases only
38
37
  def self.ignore_access_control (state = nil) # :nodoc:
39
- false
38
+ Thread.current["ignore_access_control"] = state unless state.nil?
39
+ Thread.current["ignore_access_control"] || false
40
40
  end
41
41
 
42
42
  def self.activate_authorization_rules_browser? # :nodoc:
@@ -117,10 +117,9 @@ module Authorization
117
117
  # Options:
118
118
  # [:+context+]
119
119
  # The context part of the privilege.
120
- # Defaults either to the +table_name+ of the given :+object+, if given.
121
- # That is, either :+users+ for :+object+ of type User.
122
- # Raises AuthorizationUsageError if
123
- # context is missing and not to be infered.
120
+ # Defaults either to the tableized +class_name+ of the given :+object+, if given.
121
+ # That is, :+users+ for :+object+ of type User.
122
+ # Raises AuthorizationUsageError if context is missing and not to be infered.
124
123
  # [:+object+] An context object to test attribute checks against.
125
124
  # [:+skip_attribute_test+]
126
125
  # Skips those attribute checks in the
@@ -153,7 +152,11 @@ module Authorization
153
152
  options[:object] = options[:object].new
154
153
  end
155
154
 
156
- options[:context] ||= options[:object] && options[:object].class.table_name.to_sym rescue NoMethodError
155
+ options[:context] ||= options[:object] && (
156
+ options[:object].class.respond_to?(:decl_auth_context) ?
157
+ options[:object].class.decl_auth_context :
158
+ options[:object].class.name.tableize.to_sym
159
+ ) rescue NoMethodError
157
160
 
158
161
  user, roles, privileges = user_roles_privleges_from_options(privilege, options)
159
162
 
@@ -100,7 +100,7 @@ module Authorization
100
100
  SMALL_ROLES_RATIO = 0.2
101
101
 
102
102
  def analyze_policy
103
- small_roles.count > 1 and small_roles.count.to_f / roles.count.to_f > SMALL_ROLES_RATIO
103
+ small_roles.length > 1 and small_roles.length.to_f / roles.length.to_f > SMALL_ROLES_RATIO
104
104
  end
105
105
 
106
106
  def message (object)
@@ -109,7 +109,7 @@ module Authorization
109
109
 
110
110
  private
111
111
  def small_roles
112
- roles.select {|role| role.rules.count < SMALL_ROLE_RULES_COUNT }
112
+ roles.select {|role| role.rules.length < SMALL_ROLE_RULES_COUNT }
113
113
  end
114
114
  end
115
115
 
@@ -8,7 +8,6 @@ module Authorization
8
8
  # * Objective function:
9
9
  # * affected user count,
10
10
  # * as specific as possible (roles, privileges)
11
- # -> counter-productive?
12
11
  # * as little changes as necessary
13
12
  # * Modify role, privilege hierarchy
14
13
  # * Merge, split roles
@@ -18,7 +17,7 @@ module Authorization
18
17
  # * group similar candidates: only show abstract methods?
19
18
  # * restructure GUI layout: more room for analyzing suggestions
20
19
  # * changelog, previous tests, etc.
21
- # * different permissions in tests
20
+ # * multiple permissions in tests
22
21
  # * Evaluation of approaches with Analyzer algorithms
23
22
  # * Authorization constraints
24
23
  #
@@ -41,6 +40,11 @@ module Authorization
41
40
  #
42
41
  class ChangeSupporter < AbstractAnalyzer
43
42
 
43
+ # Returns a list of possible approaches for changes to the current
44
+ # authorization rules that achieve a given goal. The goal is given as
45
+ # permission tests in the block. The instance method +users+ is available
46
+ # when the block is executed to refer to the then-current users, whose
47
+ # roles might have changed as one suggestion.
44
48
  def find_approaches_for (options, &tests)
45
49
  @prohibited_actions = (options[:prohibited_actions] || []).to_set
46
50
 
@@ -63,10 +67,29 @@ module Authorization
63
67
  end
64
68
 
65
69
  # remove subsets
66
-
67
70
  suggestions.sort!
68
71
  end
69
72
 
73
+ # Returns an array of GroupedApproaches for the given array of approaches.
74
+ # Only groups directly adjacent approaches
75
+ def group_approaches (approaches)
76
+ approaches.each_with_object([]) do |approach, grouped|
77
+ if grouped.last and grouped.last.approach.similar_to(approach)
78
+ grouped.last.similar_approaches << approach
79
+ else
80
+ grouped << GroupedApproach.new(approach)
81
+ end
82
+ end
83
+ end
84
+
85
+ class GroupedApproach
86
+ attr_accessor :approach, :similar_approaches
87
+ def initialize (approach)
88
+ @approach = approach
89
+ @similar_approaches = []
90
+ end
91
+ end
92
+
70
93
  class ApproachChecker
71
94
  attr_reader :users, :failed_tests
72
95
 
@@ -119,6 +142,15 @@ module Authorization
119
142
  res
120
143
  end
121
144
 
145
+ def affected_users (original_engine, original_users, privilege, context)
146
+ (0...@users.length).select do |i|
147
+ original_engine.permit?(privilege, :context => context,
148
+ :skip_attribute_test => true, :user => original_users[i]) !=
149
+ @engine.permit?(privilege, :context => context,
150
+ :skip_attribute_test => true, :user => @users[i])
151
+ end.collect {|i| original_users[i]}
152
+ end
153
+
122
154
  def initialize_copy (other)
123
155
  @engine = @engine.clone
124
156
  @users = @users.clone
@@ -171,7 +203,17 @@ module Authorization
171
203
  end
172
204
 
173
205
  def sort_value
174
- changes.sum(&:weight) + @failed_tests.length
206
+ weight + @failed_tests.length
207
+ end
208
+
209
+ def weight
210
+ changes.sum(&:weight)
211
+ end
212
+
213
+ def similar_to (other)
214
+ other.weight == weight and
215
+ other.changes.map {|change| change.class.name}.sort ==
216
+ changes.map {|change| change.class.name}.sort
175
217
  end
176
218
 
177
219
  def inspect