declarative_authorization 0.4.1 → 0.5
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG +15 -0
- data/README.rdoc +8 -7
- data/Rakefile +0 -8
- data/app/controllers/authorization_rules_controller.rb +1 -1
- data/app/helpers/authorization_rules_helper.rb +3 -3
- data/config/routes.rb +5 -2
- data/lib/declarative_authorization.rb +2 -0
- data/lib/declarative_authorization/authorization.rb +20 -14
- data/lib/declarative_authorization/in_controller.rb +2 -5
- data/lib/declarative_authorization/in_model.rb +13 -10
- data/lib/declarative_authorization/maintenance.rb +4 -4
- data/lib/declarative_authorization/obligation_scope.rb +51 -13
- data/lib/declarative_authorization/rails_legacy.rb +9 -1
- data/lib/declarative_authorization/railsengine.rb +6 -0
- data/lib/declarative_authorization/reader.rb +61 -7
- data/lib/tasks/authorization_tasks.rake +82 -0
- data/test/authorization_test.rb +108 -0
- data/test/controller_test.rb +4 -3
- data/test/dsl_reader_test.rb +21 -0
- data/test/helper_test.rb +1 -0
- data/test/model_test.rb +229 -91
- data/test/test_helper.rb +44 -13
- metadata +23 -9
data/CHANGELOG
CHANGED
@@ -1,3 +1,18 @@
|
|
1
|
+
|
2
|
+
** RELEASE 0.5 (July 21, 2010) **
|
3
|
+
|
4
|
+
* Ruby 1.9.2 compatibility [sb]
|
5
|
+
|
6
|
+
* Comparisons in authorization roles: lt, lte, gt, gte [aepstein,hollownest]
|
7
|
+
|
8
|
+
* DSL optimization: allow array being passed to to
|
9
|
+
|
10
|
+
* Omnipotent roles [timcharper]
|
11
|
+
|
12
|
+
* Meaningful error in case of missing authorization rules file [timcharper]
|
13
|
+
|
14
|
+
* Rails 3 support [sb]
|
15
|
+
|
1
16
|
* Support shallow nested resources [jjb]
|
2
17
|
|
3
18
|
* Allow multiple authorization rules files [kaichen]
|
data/README.rdoc
CHANGED
@@ -277,8 +277,9 @@ Privilege hierarchies may be context-specific, e.g. applicable to :employees.
|
|
277
277
|
privilege :manage, :employees, :includes => :increase_salary
|
278
278
|
end
|
279
279
|
|
280
|
-
For more complex use cases, authorizations need to be based on attributes.
|
281
|
-
|
280
|
+
For more complex use cases, authorizations need to be based on attributes. Note
|
281
|
+
that you then also need to set :attribute_check => true in controllers for filter_access_to.
|
282
|
+
E.g. if a branch admin should manage only employees of his branch (see
|
282
283
|
Authorization::Reader in the API docs for a full list of available operators):
|
283
284
|
|
284
285
|
authorization do
|
@@ -379,7 +380,7 @@ Then,
|
|
379
380
|
|
380
381
|
== Providing the Plugin's Requirements
|
381
382
|
The requirements are
|
382
|
-
* Rails >= 2.
|
383
|
+
* Rails >= 2.2, including 3 and Ruby >= 1.8.6, including 1.9
|
383
384
|
* An authentication mechanism
|
384
385
|
* A user object returned by Controller#current_user
|
385
386
|
* An array of role symbols returned by User#role_symbols
|
@@ -490,10 +491,10 @@ sbartsch at tzi.org
|
|
490
491
|
|
491
492
|
= Contributors
|
492
493
|
|
493
|
-
Thanks to John Joseph Bachir, Eike Carls, Kai Chen, Erik Dahlstrand,
|
494
|
-
|
495
|
-
Georg Ledermann, Geoff Longman, Olly Lylo, Mark Mansour,
|
496
|
-
Mike Vincent
|
494
|
+
Thanks to John Joseph Bachir, Eike Carls, Kai Chen, Erik Dahlstrand, Jeroen van Dijk,
|
495
|
+
Alexander Dobriakov, Sebastian Dyck, Ari Epstein, Jeremy Friesen, Tim Harper, hollownest,
|
496
|
+
Daniel Kristensen, Brian Langenfeld, Georg Ledermann, Geoff Longman, Olly Lylo, Mark Mansour,
|
497
|
+
Thomas Maurer, TJ Singleton, Mike Vincent
|
497
498
|
|
498
499
|
|
499
500
|
= Licence
|
data/Rakefile
CHANGED
@@ -33,11 +33,3 @@ desc "clone the garlic repo (for running ci tasks)"
|
|
33
33
|
task :get_garlic do
|
34
34
|
sh "git clone git://github.com/ianwhite/garlic.git garlic"
|
35
35
|
end
|
36
|
-
|
37
|
-
desc "Expand filelist in src gemspec"
|
38
|
-
task :build_gemspec do
|
39
|
-
gemspec_data = File.read("declarative_authorization.gemspec.src")
|
40
|
-
gemspec_data.gsub!(/\.files = (.*)/) {|m| ".files = #{eval($1).inspect}"}
|
41
|
-
File.open("declarative_authorization.gemspec", "w") {|f| f.write(gemspec_data)}
|
42
|
-
end
|
43
|
-
|
@@ -18,7 +18,7 @@ class AuthorizationRulesController < ApplicationController
|
|
18
18
|
def index
|
19
19
|
respond_to do |format|
|
20
20
|
format.html do
|
21
|
-
@auth_rules_script = File.read("#{
|
21
|
+
@auth_rules_script = File.read("#{::Rails.root}/config/authorization_rules.rb")
|
22
22
|
end
|
23
23
|
end
|
24
24
|
end
|
@@ -36,14 +36,14 @@ module AuthorizationRulesHelper
|
|
36
36
|
note = %Q{<span class="note" title="#{h text}">[i]</span>}
|
37
37
|
marked_up_by_line[line - 1] = note + marked_up_by_line[line - 1]
|
38
38
|
end
|
39
|
-
marked_up_by_line * "\n"
|
39
|
+
(marked_up_by_line * "\n").html_safe
|
40
40
|
end
|
41
|
-
|
41
|
+
|
42
42
|
def link_to_graph (title, options = {})
|
43
43
|
type = options[:type] || ''
|
44
44
|
link_to_function title, "$$('object')[0].data = '#{url_for :action => 'index', :format => 'svg', :type => type}'"
|
45
45
|
end
|
46
|
-
|
46
|
+
|
47
47
|
def navigation
|
48
48
|
link_to("Rules", authorization_rules_path) << ' | ' <<
|
49
49
|
link_to("Change Support", change_authorization_rules_path) << ' | ' <<
|
data/config/routes.rb
CHANGED
@@ -1,6 +1,9 @@
|
|
1
|
-
ActionController::Routing::Routes
|
1
|
+
# Rails 3 depreciates ActionController::Routing::Routes
|
2
|
+
routes = (Rails.respond_to?(:application) ? Rails.application.routes : ActionController::Routing::Routes)
|
3
|
+
|
4
|
+
routes.draw do |map|
|
2
5
|
if Authorization::activate_authorization_rules_browser?
|
3
|
-
map.resources :authorization_rules, :only => [:index],
|
6
|
+
map.resources :authorization_rules, :only => [:index],
|
4
7
|
:collection => {:graph => :get, :change => :get, :suggest_change => :get}
|
5
8
|
map.resources :authorization_usages, :only => :index
|
6
9
|
end
|
@@ -9,6 +9,8 @@ if Rails::VERSION::STRING < min_rails_version
|
|
9
9
|
raise "declarative_authorization requires Rails #{min_rails_version}. You are using #{Rails::VERSION::STRING}."
|
10
10
|
end
|
11
11
|
|
12
|
+
require File.join(%w{declarative_authorization railsengine}) if defined?(::Rails::Engine)
|
13
|
+
|
12
14
|
ActionController::Base.send :include, Authorization::AuthorizationInController
|
13
15
|
ActionController::Base.helper Authorization::AuthorizationHelper
|
14
16
|
|
@@ -20,7 +20,7 @@ module Authorization
|
|
20
20
|
# The exception is raised to ensure that the entire rule is invalidated.
|
21
21
|
class NilAttributeValueError < AuthorizationError; end
|
22
22
|
|
23
|
-
AUTH_DSL_FILES = ["
|
23
|
+
AUTH_DSL_FILES = [(Rails.root || Pathname.new('')).join("config", "authorization_rules.rb").to_s] unless defined? AUTH_DSL_FILES
|
24
24
|
|
25
25
|
# Controller-independent method for retrieving the current user.
|
26
26
|
# Needed for model security where the current controller is not available.
|
@@ -40,7 +40,7 @@ module Authorization
|
|
40
40
|
end
|
41
41
|
|
42
42
|
def self.activate_authorization_rules_browser? # :nodoc:
|
43
|
-
::
|
43
|
+
::Rails.env.development?
|
44
44
|
end
|
45
45
|
|
46
46
|
@@dot_path = "dot"
|
@@ -57,7 +57,7 @@ module Authorization
|
|
57
57
|
# a certain privilege is granted for the current user.
|
58
58
|
#
|
59
59
|
class Engine
|
60
|
-
attr_reader :roles, :role_titles, :role_descriptions, :privileges,
|
60
|
+
attr_reader :roles, :omnipotent_roles, :role_titles, :role_descriptions, :privileges,
|
61
61
|
:privilege_hierarchy, :auth_rules, :role_hierarchy, :rev_priv_hierarchy,
|
62
62
|
:rev_role_hierarchy
|
63
63
|
|
@@ -65,20 +65,14 @@ module Authorization
|
|
65
65
|
# authorization configuration of +AUTH_DSL_FILES+. If given, may be either
|
66
66
|
# a Reader object or a path to a configuration file.
|
67
67
|
def initialize (reader = nil)
|
68
|
-
|
69
|
-
|
70
|
-
reader = Reader::DSLReader.load(AUTH_DSL_FILES)
|
71
|
-
rescue SystemCallError
|
72
|
-
reader = Reader::DSLReader.new
|
73
|
-
end
|
74
|
-
elsif reader.is_a?(String)
|
75
|
-
reader = Reader::DSLReader.load(reader)
|
76
|
-
end
|
68
|
+
reader = Reader::DSLReader.factory(reader || AUTH_DSL_FILES)
|
69
|
+
|
77
70
|
@privileges = reader.privileges_reader.privileges
|
78
71
|
# {priv => [[priv, ctx],...]}
|
79
72
|
@privilege_hierarchy = reader.privileges_reader.privilege_hierarchy
|
80
73
|
@auth_rules = reader.auth_rules_reader.auth_rules
|
81
74
|
@roles = reader.auth_rules_reader.roles
|
75
|
+
@omnipotent_roles = reader.auth_rules_reader.omnipotent_roles
|
82
76
|
@role_hierarchy = reader.auth_rules_reader.role_hierarchy
|
83
77
|
|
84
78
|
@role_titles = reader.auth_rules_reader.role_titles
|
@@ -160,6 +154,8 @@ module Authorization
|
|
160
154
|
|
161
155
|
user, roles, privileges = user_roles_privleges_from_options(privilege, options)
|
162
156
|
|
157
|
+
return true if roles.is_a?(Array) and not (roles & @omnipotent_roles).empty?
|
158
|
+
|
163
159
|
# find a authorization rule that matches for at least one of the roles and
|
164
160
|
# at least one of the given privileges
|
165
161
|
attr_validator = AttributeValidator.new(self, user, options[:object], privilege, options[:context])
|
@@ -477,6 +473,14 @@ module Authorization
|
|
477
473
|
"subclass of Enumerable as value, got: #{attr_value.inspect} " +
|
478
474
|
"is_not_in #{evaluated.inspect}: #{e}"
|
479
475
|
end
|
476
|
+
when :lt
|
477
|
+
attr_value && attr_value < evaluated
|
478
|
+
when :lte
|
479
|
+
attr_value && attr_value <= evaluated
|
480
|
+
when :gt
|
481
|
+
attr_value && attr_value > evaluated
|
482
|
+
when :gte
|
483
|
+
attr_value && attr_value >= evaluated
|
480
484
|
else
|
481
485
|
raise AuthorizationError, "Unknown operator #{value[0]}"
|
482
486
|
end
|
@@ -521,8 +525,9 @@ module Authorization
|
|
521
525
|
begin
|
522
526
|
object.send(attr)
|
523
527
|
rescue ArgumentError, NoMethodError => e
|
524
|
-
raise AuthorizationUsageError, "Error
|
525
|
-
|
528
|
+
raise AuthorizationUsageError, "Error occurred while validating attribute ##{attr} on #{object.inspect}: #{e}.\n" +
|
529
|
+
"Please check your authorization rules and ensure the attribute is correctly spelled and \n" +
|
530
|
+
"corresponds to a method on the model you are authorizing for."
|
526
531
|
end
|
527
532
|
end
|
528
533
|
|
@@ -679,3 +684,4 @@ module Authorization
|
|
679
684
|
end
|
680
685
|
end
|
681
686
|
end
|
687
|
+
|
@@ -112,7 +112,7 @@ module Authorization
|
|
112
112
|
else
|
113
113
|
!DEFAULT_DENY
|
114
114
|
end
|
115
|
-
rescue
|
115
|
+
rescue NotAuthorized => e
|
116
116
|
auth_exception = e
|
117
117
|
end
|
118
118
|
|
@@ -274,10 +274,7 @@ module Authorization
|
|
274
274
|
context = options[:context]
|
275
275
|
actions = args.flatten
|
276
276
|
|
277
|
-
|
278
|
-
unless filter_chain.any? {|filter| filter.method == :filter_access_filter}
|
279
|
-
before_filter :filter_access_filter
|
280
|
-
end
|
277
|
+
before_filter :filter_access_filter
|
281
278
|
|
282
279
|
filter_access_permissions.each do |perm|
|
283
280
|
perm.remove_actions(actions)
|
@@ -69,11 +69,12 @@ module Authorization
|
|
69
69
|
}.merge(options)
|
70
70
|
engine = options[:engine] || Authorization::Engine.instance
|
71
71
|
|
72
|
-
|
72
|
+
obligation_scope = ObligationScope.new( options[:model], {} )
|
73
73
|
engine.obligations( privileges, :user => options[:user], :context => options[:context] ).each do |obligation|
|
74
|
-
|
74
|
+
obligation_scope.parse!( obligation )
|
75
75
|
end
|
76
|
-
|
76
|
+
|
77
|
+
obligation_scope.scope
|
77
78
|
end
|
78
79
|
|
79
80
|
# Named scope for limiting query results according to the authorization
|
@@ -132,15 +133,17 @@ module Authorization
|
|
132
133
|
:object => object, :context => options[:context])
|
133
134
|
end
|
134
135
|
end
|
135
|
-
|
136
|
-
# after_find is only called if after_find is implemented
|
137
|
-
after_find do |object|
|
138
|
-
Authorization::Engine.instance.permit!(:read, :object => object,
|
139
|
-
:context => options[:context])
|
140
|
-
end
|
141
136
|
|
142
137
|
if options[:include_read]
|
143
|
-
|
138
|
+
# after_find is only called if after_find is implemented
|
139
|
+
after_find do |object|
|
140
|
+
Authorization::Engine.instance.permit!(:read, :object => object,
|
141
|
+
:context => options[:context])
|
142
|
+
end
|
143
|
+
|
144
|
+
if Rails.version < "3"
|
145
|
+
def after_find; end
|
146
|
+
end
|
144
147
|
end
|
145
148
|
|
146
149
|
def self.using_access_control?
|
@@ -55,9 +55,9 @@ module Authorization
|
|
55
55
|
def self.usages_by_controller
|
56
56
|
# load each application controller
|
57
57
|
begin
|
58
|
-
Dir.foreach(File.join(
|
58
|
+
Dir.foreach(File.join(::Rails.root, %w{app controllers})) do |entry|
|
59
59
|
if entry =~ /^\w+_controller\.rb$/
|
60
|
-
require File.join(
|
60
|
+
require File.join(::Rails.root, %w{app controllers}, entry)
|
61
61
|
end
|
62
62
|
end
|
63
63
|
rescue Errno::ENOENT
|
@@ -78,7 +78,7 @@ module Authorization
|
|
78
78
|
end
|
79
79
|
end
|
80
80
|
|
81
|
-
actions = controller.public_instance_methods(false) - controller.hidden_actions
|
81
|
+
actions = controller.public_instance_methods(false) - controller.hidden_actions.to_a
|
82
82
|
memo[controller] = actions.inject({}) do |actions_memo, action|
|
83
83
|
action_sym = action.to_sym
|
84
84
|
actions_memo[action_sym] =
|
@@ -171,7 +171,7 @@ module Authorization
|
|
171
171
|
|
172
172
|
def request_with (user, method, xhr, action, params = {},
|
173
173
|
session = {}, flash = {})
|
174
|
-
session = session.merge({:user => user, :user_id => user.id})
|
174
|
+
session = session.merge({:user => user, :user_id => user && user.id})
|
175
175
|
with_user(user) do
|
176
176
|
if xhr
|
177
177
|
xhr method, action, params, session, flash
|
@@ -33,6 +33,7 @@ module Authorization
|
|
33
33
|
# [ :attr, :is, <user.id> ]
|
34
34
|
# ]+
|
35
35
|
#
|
36
|
+
# TODO update doc for Relations:
|
36
37
|
# After successfully parsing an obligation, all of the stored paths and conditions are converted
|
37
38
|
# into scope options (stored in +proxy_options+ as +:joins+ and +:conditions+). The resulting
|
38
39
|
# scope may then be used to find all scoped objects for which at least one of the parsed
|
@@ -41,8 +42,25 @@ module Authorization
|
|
41
42
|
# +@proxy_options[:joins] = { :bar => { :baz => :foo } }
|
42
43
|
# @proxy_options[:conditions] = [ 'foos_bazzes.attr = :foos_bazzes__id_0', { :foos_bazzes__id_0 => 1 } ]+
|
43
44
|
#
|
44
|
-
class ObligationScope < ActiveRecord::NamedScope::Scope
|
45
|
-
|
45
|
+
class ObligationScope < (Rails.version < "3" ? ActiveRecord::NamedScope::Scope : ActiveRecord::Relation)
|
46
|
+
def initialize (model, options)
|
47
|
+
@finder_options = {}
|
48
|
+
if Rails.version < "3"
|
49
|
+
super(model, options)
|
50
|
+
else
|
51
|
+
super(model, model.table_name)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def scope
|
56
|
+
if Rails.version < "3"
|
57
|
+
self
|
58
|
+
else
|
59
|
+
# for Rails < 3: scope, after setting proxy_options
|
60
|
+
self.klass.scoped(@finder_options)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
46
64
|
# Consumes the given obligation, converting it into scope join and condition options.
|
47
65
|
def parse!( obligation )
|
48
66
|
@current_obligation = obligation
|
@@ -79,6 +97,18 @@ module Authorization
|
|
79
97
|
raise "invalid obligation path #{[past_steps, steps].inspect}"
|
80
98
|
end
|
81
99
|
end
|
100
|
+
|
101
|
+
def top_level_model
|
102
|
+
if Rails.version < "3"
|
103
|
+
@proxy_scope
|
104
|
+
else
|
105
|
+
self.klass
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def finder_options
|
110
|
+
Rails.version < "3" ? @proxy_options : @finder_options
|
111
|
+
end
|
82
112
|
|
83
113
|
# At the end of every association path, we expect to see a comparison of some kind; for
|
84
114
|
# example, +:attr => [ :is, :value ]+.
|
@@ -135,7 +165,7 @@ module Authorization
|
|
135
165
|
def map_reflection_for( path )
|
136
166
|
raise "reflection for #{path.inspect} already exists" unless reflections[path].nil?
|
137
167
|
|
138
|
-
reflection = path.empty? ?
|
168
|
+
reflection = path.empty? ? top_level_model : begin
|
139
169
|
parent = reflection_for( path[0..-2] )
|
140
170
|
if !parent.respond_to?(:proxy_reflection) and parent.respond_to?(:klass)
|
141
171
|
parent.klass.reflect_on_association( path.last )
|
@@ -151,7 +181,8 @@ module Authorization
|
|
151
181
|
map_table_alias_for( path ) # Claim a table alias for the path.
|
152
182
|
|
153
183
|
# Claim alias for join table
|
154
|
-
|
184
|
+
# TODO change how this is checked
|
185
|
+
if !reflection.respond_to?(:proxy_reflection) and !reflection.respond_to?(:proxy_scope) and reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
155
186
|
join_table_path = path[0..-2] + [reflection.options[:through]]
|
156
187
|
reflection_for(join_table_path, true)
|
157
188
|
end
|
@@ -209,7 +240,7 @@ module Authorization
|
|
209
240
|
conditions.each do |path, expressions|
|
210
241
|
model = model_for( path )
|
211
242
|
table_alias = table_alias_for(path)
|
212
|
-
parent_model = (path.length > 1 ? model_for(path[0..-2]) :
|
243
|
+
parent_model = (path.length > 1 ? model_for(path[0..-2]) : top_level_model)
|
213
244
|
expressions.each do |expression|
|
214
245
|
attribute, operator, value = expression
|
215
246
|
# prevent unnecessary joins:
|
@@ -227,7 +258,8 @@ module Authorization
|
|
227
258
|
end
|
228
259
|
bindvar = "#{attribute_table_alias}__#{attribute_name}_#{obligation_index}".to_sym
|
229
260
|
|
230
|
-
sql_attribute = "#{connection.quote_table_name(attribute_table_alias)}
|
261
|
+
sql_attribute = "#{parent_model.connection.quote_table_name(attribute_table_alias)}." +
|
262
|
+
"#{parent_model.connection.quote_table_name(attribute_name)}"
|
231
263
|
if value.nil? and [:is, :is_not].include?(operator)
|
232
264
|
obligation_conds << "#{sql_attribute} IS #{[:contains, :is].include?(operator) ? '' : 'NOT '}NULL"
|
233
265
|
else
|
@@ -236,6 +268,10 @@ module Authorization
|
|
236
268
|
when :does_not_contain, :is_not then "<> :#{bindvar}"
|
237
269
|
when :is_in, :intersects_with then "IN (:#{bindvar})"
|
238
270
|
when :is_not_in then "NOT IN (:#{bindvar})"
|
271
|
+
when :lt then "< :#{bindvar}"
|
272
|
+
when :lte then "<= :#{bindvar}"
|
273
|
+
when :gt then "> :#{bindvar}"
|
274
|
+
when :gte then ">= :#{bindvar}"
|
239
275
|
else raise AuthorizationUsageError, "Unknown operator: #{operator}"
|
240
276
|
end
|
241
277
|
obligation_conds << "#{sql_attribute} #{attribute_operator}"
|
@@ -247,7 +283,8 @@ module Authorization
|
|
247
283
|
conds << "(#{obligation_conds.join(' AND ')})"
|
248
284
|
end
|
249
285
|
(delete_paths - used_paths).each {|path| reflections.delete(path)}
|
250
|
-
|
286
|
+
|
287
|
+
finder_options[:conditions] = [ conds.join( " OR " ), binds ]
|
251
288
|
end
|
252
289
|
|
253
290
|
def attribute_value (value)
|
@@ -259,7 +296,7 @@ module Authorization
|
|
259
296
|
# Parses all of the defined obligation joins and defines the scope's :joins or :includes option.
|
260
297
|
# TODO: Support non-linear association paths. Right now, we just break down the longest path parsed.
|
261
298
|
def rebuild_join_options!
|
262
|
-
joins = (
|
299
|
+
joins = (finder_options[:joins] || []) + (finder_options[:includes] || [])
|
263
300
|
|
264
301
|
reflections.keys.each do |path|
|
265
302
|
next if path.empty? or @join_table_joins.include?(path)
|
@@ -283,11 +320,11 @@ module Authorization
|
|
283
320
|
when 0 then
|
284
321
|
# No obligation conditions means we don't have to mess with joins or includes at all.
|
285
322
|
when 1 then
|
286
|
-
|
287
|
-
|
323
|
+
finder_options[:joins] = joins
|
324
|
+
finder_options.delete( :include )
|
288
325
|
else
|
289
|
-
|
290
|
-
|
326
|
+
finder_options.delete( :joins )
|
327
|
+
finder_options[:include] = joins
|
291
328
|
end
|
292
329
|
end
|
293
330
|
|
@@ -313,4 +350,5 @@ module Authorization
|
|
313
350
|
end
|
314
351
|
end
|
315
352
|
end
|
316
|
-
end
|
353
|
+
end
|
354
|
+
|